From c9da4ceddac5995f16b124c9054ba2d10d58560a Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Fri, 11 Sep 2026 12:56:19 -0500 Subject: [PATCH 01/37] Registry open must not take a write lock when there is nothing to migrate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-09-11 annotation-type rename ran an unconditional UPDATE on project_published_annotation_types inside _init_schema, before CREATE INDEX IF NOT EXISTS on the same table. Under Postgres an UPDATE takes RowExclusiveLock even when it matches no rows, CREATE INDEX wants ShareLock, and two processes opening the registry at once each held the first while waiting for the second. The answer layer opens one registry per question, sixteen at a time, so the Dashboard by question hit it on every load: 500s on most answers, and a pane reporting "not answered — 0 of 1 reported" over measurements that were there. Reproduced with sixteen parallel opens against the live registry: 14 of 16 deadlocked. With this change: 16 of 16 clean. The migration now reads first and updates only when a stale row exists — which is no process after the first — and sits after the index, so lock order matches. A test pins the shape SQLite can check: a fresh init issues no UPDATE against that table. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- .../resource_explorer/registry.py | 37 ++++++++++----- ...it_schema_takes_no_write_lock_when_idle.py | 45 +++++++++++++++++++ 2 files changed, 71 insertions(+), 11 deletions(-) create mode 100644 packages/resource-explorer/tests/test_init_schema_takes_no_write_lock_when_idle.py diff --git a/packages/resource-explorer/resource_explorer/registry.py b/packages/resource-explorer/resource_explorer/registry.py index 27554ec0a..6a6ac27c8 100644 --- a/packages/resource-explorer/resource_explorer/registry.py +++ b/packages/resource-explorer/resource_explorer/registry.py @@ -1057,25 +1057,40 @@ def _init_schema(self) -> None: egeria_report_guid TEXT NOT NULL DEFAULT '' ) """) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_published_annotation_types_slug " + "ON project_published_annotation_types(project_slug, annotation_type)" + ) # Migration 2026-09-11: two AnnotationType values were shortened # forms of the Egeria type name ("SchemaAnalysis", # "RequestForAction") while the catalog declared the full names. # Rows written under the short names are renamed so the - # declared-vs-received join reads them. Idempotent: a second run - # matches nothing. + # declared-vs-received join reads them. + # + # Guarded by a read, and placed AFTER the index. The first version + # ran the UPDATE unconditionally, before CREATE INDEX IF NOT + # EXISTS, and deadlocked every process that opened the registry + # concurrently: an UPDATE takes RowExclusiveLock even when it + # matches no rows, CREATE INDEX wants ShareLock, and two openers + # each held the first while waiting for the second. The answer + # layer opens one registry per question, sixteen at a time, so + # the Dashboard by question hit it on every load. Now a process + # that finds nothing to rename takes no write lock at all, which + # is every process after the first. for short, full in ( ("SchemaAnalysis", "SchemaAnalysisAnnotation"), ("RequestForAction", "RequestForActionAnnotation"), ): - conn.execute( - "UPDATE project_published_annotation_types SET annotation_type = ? " - "WHERE annotation_type = ?", - (full, short), - ) - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_published_annotation_types_slug " - "ON project_published_annotation_types(project_slug, annotation_type)" - ) + stale = conn.execute( + "SELECT 1 FROM project_published_annotation_types WHERE annotation_type = ? LIMIT 1", + (short,), + ).fetchone() + if stale: + conn.execute( + "UPDATE project_published_annotation_types SET annotation_type = ? " + "WHERE annotation_type = ?", + (full, short), + ) # Which ANALYSES a publish covered, recorded directly. # # project_published_annotation_types above answers the same diff --git a/packages/resource-explorer/tests/test_init_schema_takes_no_write_lock_when_idle.py b/packages/resource-explorer/tests/test_init_schema_takes_no_write_lock_when_idle.py new file mode 100644 index 000000000..4527df67d --- /dev/null +++ b/packages/resource-explorer/tests/test_init_schema_takes_no_write_lock_when_idle.py @@ -0,0 +1,45 @@ +"""Opening the registry must not write when there is nothing to migrate. + +The 2026-09-11 annotation-type rename ran an unconditional UPDATE inside +_init_schema, before CREATE INDEX IF NOT EXISTS on the same table. Under +Postgres an UPDATE takes RowExclusiveLock even when it matches no rows, CREATE +INDEX wants ShareLock, and two processes opening the registry at once each +held the first while waiting for the second: a deadlock on every concurrent +open. Reproduced with sixteen parallel opens against the live registry — +14 of 16 deadlocked — and fixed by reading first and updating only when a +stale row exists. + +SQLite has no such lock, so this asserts the *shape* the fix relies on: a +fresh registry's init issues no UPDATE against that table. If someone later +adds another unconditional write to _init_schema, this is what fails. +""" +from __future__ import annotations + +from resource_explorer.registry import ProjectRegistry + + +def test_fresh_registry_init_issues_no_update_on_published_annotation_types(tmp_path, monkeypatch): + r = ProjectRegistry(db_path=str(tmp_path / "t.db")) + + seen: list[str] = [] + real_conn = r._conn + + class SpyCtx: + def __init__(self, ctx): self.ctx = ctx + def __enter__(self): + conn = self.ctx.__enter__() + real = conn.execute + def spy(sql, *a, **k): + seen.append(" ".join(str(sql).split())) + return real(sql, *a, **k) + conn.execute = spy + return conn + def __exit__(self, *a): return self.ctx.__exit__(*a) + + monkeypatch.setattr(r, "_conn", lambda: SpyCtx(real_conn())) + r._init_schema() + + updates = [s for s in seen if s.upper().startswith("UPDATE PROJECT_PUBLISHED_ANNOTATION_TYPES")] + assert updates == [], f"init wrote to project_published_annotation_types with nothing to migrate: {updates}" + # and the guard read DID run, so the migration is still present + assert any("SELECT 1 FROM project_published_annotation_types" in s for s in seen) From eb82a64ef106d73681d68bd97f158a80a1b7c9aa Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Fri, 11 Sep 2026 13:05:29 -0500 Subject: [PATCH 02/37] Dashboard round two: the answer tier the regrouping was meant to unlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DASHBOARD-ROUND-TWO.md, all five rules, on the by-question view. The grouping by question stood; what was missing was its payoff — a reader still assembled every answer from raw rows, five times, down the page. 1. Every section opens with its answer, relayed from the answer layer — the same sentence the Evidence rail shows, not one composed here. A question the layer cannot answer says "not answered — 0 of 3 analyses reported", which is a state worth seeing. A human-answered question shows what the person said, or says that nobody has, rather than pretending its informant analyses decide it. 2. Findings that agree collapse to one row with a count and the check names, the full rows behind a disclosure. Unresolved ones stay individual and first: each is a decision. 3. Provenance is section-level — "3 analyses · 31 measurements · latest 8d ago". The analysis name leaves every row; it is in the popup beside the run, which is where it matters. 4. A disagreement is the section's headline, both values side by side with the analyses that reported them, above the detail. Shown once: a second question that reaches the same conflict says so and links to the first. The by-question view had lost this marking entirely; it is back. 5. Booleans leave the counts table for the answer line; unsets render as "not recorded: …", never as a number. A zero stays a zero — whether it means none or not-set is the producer's to say. Plus an anchor rail — every question with its state glyph, jump to any — and the header sentence "N of these are not built in /next" removed: review- speak, unreadable in the product, and the dashed tab already says it. The detail under each section is collapsed by default. 4,811px for sixteen questions and the unasked section, from roughly double. Also carries the _init_schema deadlock fix (#30), without which this pane could not be verified: the answer layer opens one registry per question and sixteen at once deadlocked on the unconditional migration UPDATE. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- .../resource_explorer/registry.py | 37 ++- .../resource_explorer/web/static/next/app.js | 298 ++++++++++++++---- ...it_schema_takes_no_write_lock_when_idle.py | 45 +++ 3 files changed, 305 insertions(+), 75 deletions(-) create mode 100644 packages/resource-explorer/tests/test_init_schema_takes_no_write_lock_when_idle.py diff --git a/packages/resource-explorer/resource_explorer/registry.py b/packages/resource-explorer/resource_explorer/registry.py index 27554ec0a..6a6ac27c8 100644 --- a/packages/resource-explorer/resource_explorer/registry.py +++ b/packages/resource-explorer/resource_explorer/registry.py @@ -1057,25 +1057,40 @@ def _init_schema(self) -> None: egeria_report_guid TEXT NOT NULL DEFAULT '' ) """) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_published_annotation_types_slug " + "ON project_published_annotation_types(project_slug, annotation_type)" + ) # Migration 2026-09-11: two AnnotationType values were shortened # forms of the Egeria type name ("SchemaAnalysis", # "RequestForAction") while the catalog declared the full names. # Rows written under the short names are renamed so the - # declared-vs-received join reads them. Idempotent: a second run - # matches nothing. + # declared-vs-received join reads them. + # + # Guarded by a read, and placed AFTER the index. The first version + # ran the UPDATE unconditionally, before CREATE INDEX IF NOT + # EXISTS, and deadlocked every process that opened the registry + # concurrently: an UPDATE takes RowExclusiveLock even when it + # matches no rows, CREATE INDEX wants ShareLock, and two openers + # each held the first while waiting for the second. The answer + # layer opens one registry per question, sixteen at a time, so + # the Dashboard by question hit it on every load. Now a process + # that finds nothing to rename takes no write lock at all, which + # is every process after the first. for short, full in ( ("SchemaAnalysis", "SchemaAnalysisAnnotation"), ("RequestForAction", "RequestForActionAnnotation"), ): - conn.execute( - "UPDATE project_published_annotation_types SET annotation_type = ? " - "WHERE annotation_type = ?", - (full, short), - ) - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_published_annotation_types_slug " - "ON project_published_annotation_types(project_slug, annotation_type)" - ) + stale = conn.execute( + "SELECT 1 FROM project_published_annotation_types WHERE annotation_type = ? LIMIT 1", + (short,), + ).fetchone() + if stale: + conn.execute( + "UPDATE project_published_annotation_types SET annotation_type = ? " + "WHERE annotation_type = ?", + (full, short), + ) # Which ANALYSES a publish covered, recorded directly. # # project_published_annotation_types above answers the same diff --git a/packages/resource-explorer/resource_explorer/web/static/next/app.js b/packages/resource-explorer/resource_explorer/web/static/next/app.js index 1b48e8784..130516dd4 100644 --- a/packages/resource-explorer/resource_explorer/web/static/next/app.js +++ b/packages/resource-explorer/resource_explorer/web/static/next/app.js @@ -2635,13 +2635,13 @@ function subTabsHtml() { class="cursor-pointer bg-transparent text-ink-muted" style="border-bottom:1px dashed currentColor;padding-bottom:1px">${t.label}`; }).join('')} - ${ - SUB_TABS.filter((t) => !t.built && t.id !== 'questions').length - ? `${SUB_TABS.filter((t) => !t.built && t.id !== 'questions').length} of these are not built in /next` - : 'all panes built'} `; } +/* The header no longer says "N of these are not built in /next". That + * sentence was review-speak — correct in a handoff, unreadable in the + * product to anyone who was not in the conversation — and the dashed + * underline on a deferred tab already carries the fact (design rule 6). */ /** Sub-tab clicks: the real one switches, a deferred one says it is deferred. */ function bindSubTabs() { const el = $('content'); @@ -3390,26 +3390,22 @@ const humanLabel = (l) => String(l || '').replace(/_/g, ' ').toLowerCase(); */ const DASH_VIEW_KEY = 're-next.dashView'; -function factGlyph(state) { - switch (state) { - case 'measured': return { glyph: '✓', tone: 'text-state-ok' }; - case 'error': return { glyph: '✕', tone: 'text-state-warn' }; - case 'unrun': return { glyph: '○', tone: 'text-ink-muted' }; - default: return { glyph: '·', tone: 'text-ink-muted' }; - } -} -function subResourceSummaryHtml(fact) { - const rows = (fact.value && fact.value.findings) || []; - const worthy = rows.filter((r) => String(r.label || '').toLowerCase() === 'worthy').length; - return `
- ${rows.length} sub-resources assessed · - ${worthy} worthy · ${rows.length - worthy} not. - These are promotion candidates, not findings about this repository — - they belong with the verdict loop. - -
`; -} + +/* Round two of the dashboard (DASHBOARD-ROUND-TWO.md): presentation, not + * structure. The grouping by question stands; what was missing was the tier + * it was meant to unlock — a reader still assembled every answer themselves + * from raw rows, 2,300 pixels deep. Five rules, each a function below: + * + * 1. every section opens with its answer — the answer LAYER's sentence, + * the same one the Evidence rail shows, not one composed here; + * 2. findings that agree collapse to one row with a count; + * 3. provenance is section-level; the analysis name leaves every row; + * 4. a disagreement is the section's headline, shown once and pointed to + * from any other question that reaches the same conflict; + * 5. booleans and unsets leave the counts table — they are part of the + * answer, or they are "not recorded", never a number. + */ function findingRowHtml(f, analysisId, when) { const c = findingGlyph(f.label); @@ -3425,50 +3421,110 @@ function findingRowHtml(f, analysisId, when) { : esc(humanLabel(f.label) || analysisId)}. ${f.summary ? ` ${tnum(esc(f.summary))}` : ''} · - ${esc(analysisId)}${when ? ` · ${esc(ago(when))}` : ''} › + › `; } -function sortUnresolvedFirst(findings) { - return [...findings].sort((x, y) => { - const ux = UNRESOLVED_LABELS.has(String(x.label || '').toLowerCase()) ? 0 : 1; - const uy = UNRESOLVED_LABELS.has(String(y.label || '').toLowerCase()) ? 0 : 1; - return ux - uy; - }); +function isUnresolved(f) { + return UNRESOLVED_LABELS.has(String(f.label || '').toLowerCase()); +} + +/** Rule 2. Unresolved findings stay individual and first — each is a + * decision. The rest group by verdict: two or more that agree become one + * row naming the count and the checks, with the full rows behind a + * disclosure. "Five passes" is one fact, not five. */ +function findingsHtml(findings, analysisId, when) { + const open = findings.filter(isUnresolved); + const rest = findings.filter((f) => !isUnresolved(f)); + const groups = new Map(); + for (const f of rest) { + const k = String(f.label || '').toLowerCase(); + groups.set(k, [...(groups.get(k) || []), f]); + } + const out = [...open.map((f) => findingRowHtml(f, analysisId, when))]; + for (const [label, rows] of groups) { + if (rows.length === 1) { out.push(findingRowHtml(rows[0], analysisId, when)); continue; } + const c = findingGlyph(label); + const names = rows.map((f) => (f.check_name || '').replace(/_/g, ' ')).filter(Boolean); + out.push(`
+ + ${c.glyph} + ${rows.length} checks — ${esc(humanLabel(label))}. + ${esc(names.join(', '))} + show ${rows.length} + +
${rows.map((f) => findingRowHtml(f, analysisId, when)).join('')}
+
`); + } + return out.join(''); +} + +function subResourceSummaryHtml(fact) { + const rows = (fact.value && fact.value.findings) || []; + const worthy = rows.filter((r) => String(r.label || '').toLowerCase() === 'worthy').length; + return `
+ ${rows.length} sub-resources assessed · + ${worthy} worthy · ${rows.length - worthy} not. + These are promotion candidates, not findings about this repository — + they belong with the verdict loop. + +
`; +} + +function factGlyph(state) { + switch (state) { + case 'measured': return { glyph: '✓', tone: 'text-state-ok' }; + case 'error': return { glyph: '✕', tone: 'text-state-warn' }; + case 'unrun': return { glyph: '○', tone: 'text-ink-muted' }; + default: return { glyph: '·', tone: 'text-ink-muted' }; + } +} + +/** Rule 5. Split an analysis's scalar results three ways: numbers for the + * counts table; booleans, which ARE part of the answer; and unsets — null + * or empty — which are "not recorded" and must never render as a number. + * A zero is left as a zero: whether it means "none" or "not set" is the + * producer's to say, and guessing here would be the lie the round names. */ +function splitScalars(value) { + const numbers = []; const flags = []; const unset = []; + for (const [k, v] of Object.entries(value || {})) { + if (k === 'findings' || k === 'overall') continue; + if (typeof v === 'number') numbers.push({ key: k, value: v }); + else if (typeof v === 'boolean') flags.push({ key: k, value: v }); + else if (v == null || v === '') unset.push(k); + } + return { numbers, flags, unset }; } /** One analysis, under one question. `checks` is the set of check names this - * question declares for it, or null for the whole analysis. */ + * question declares for it, or null for the whole analysis. Provenance is + * NOT on the rows (rule 3): the analysis is named once, here, in the head + * line, and in the popup beside the run. */ function analysisUnderQuestionHtml(fact, id, checks) { if (!fact) { return `
${esc(id)} · not read
`; } const g = factGlyph(fact.state); const when = fact.last_run_at || ''; + const value = (fact.value && typeof fact.value === 'object') ? fact.value : {}; + let findings = Array.isArray(value.findings) ? value.findings : []; + if (checks) findings = findings.filter((f) => checks.has(String(f.check_name || ''))); + findings = sortUnresolvedFirst(findings); + const { numbers, flags, unset } = checks ? { numbers: [], flags: [], unset: [] } : splitScalars(value); const head = ``; if (id === 'sub_resource_survey') return head + subResourceSummaryHtml(fact); - const value = (fact.value && typeof fact.value === 'object') ? fact.value : {}; - let findings = Array.isArray(value.findings) ? value.findings : []; - if (checks) findings = findings.filter((f) => checks.has(String(f.check_name || ''))); - findings = sortUnresolvedFirst(findings); - const counts = checks ? [] : Object.entries(value) - .filter(([k, v]) => k !== 'findings' && k !== 'overall' && (typeof v === 'number' || typeof v === 'boolean')) - .map(([k, v]) => ({ key: k, value: v })); - // Under a check-scoped question the whole-analysis sentence is not the - // answer — "1 of 5 conventions need attention" said three times under three - // questions that each asked about ONE convention. The scoped finding IS the - // answer, and it carries the analysis id and time itself. The sentence - // stays where the whole analysis is shown. const scopedAndFound = checks && findings.length; return (scopedAndFound ? '' : head) - + (findings.length ? findings.map((f) => findingRowHtml(f, id, when)).join('') : '') - + (counts.length ? `${counts.map((c) => ` + + (findings.length ? findingsHtml(findings, id, when) : '') + + (unset.length ? `
not recorded: ${esc(unset.map((k) => k.replace(/_/g, ' ')).join(', '))}
` : '') + + (numbers.length ? `
${numbers.map((c) => ` @@ -3476,6 +3532,50 @@ function analysisUnderQuestionHtml(fact, id, checks) { `).join('')}
${esc(c.key.replace(/_/g, ' '))}
` : ''); } +function sortUnresolvedFirst(findings) { + return [...findings].sort((x, y) => (isUnresolved(x) ? 0 : 1) - (isUnresolved(y) ? 0 : 1)); +} + +/** Rule 4. Every scalar name reported by more than one analysis with + * different values, across ALL the facts on the pane — so a conflict is + * found wherever it lives and shown wherever it is reached. */ +function findDisputes(facts) { + const seen = new Map(); + for (const [id, f] of facts) { + const value = (f && f.value && typeof f.value === 'object') ? f.value : {}; + for (const [k, v] of Object.entries(value)) { + if (typeof v !== 'number') continue; + seen.set(k, [...(seen.get(k) || []), { analysis: id, value: v }]); + } + } + const disputes = new Map(); + for (const [k, rec] of seen) { + if (rec.length > 1 && new Set(rec.map((x) => x.value)).size > 1) disputes.set(k, rec); + } + return disputes; +} + +function disputeHtml(key, rec, when) { + return `
+
Disagreement · ${esc(key.replace(/_/g, ' '))}
+
+ ${rec.map((x) => ``).join('')} +
+
${rec.length} analyses report this name with different values; they may not be measuring the same thing. Open either to see what each counted.
+
`; +} + +/** The section's state, for the anchor rail: the worst thing in it. */ +function sectionState(answerEnv, facts, ids) { + const known = (answerEnv && answerEnv.facts || []).filter((f) => f.is_known); + if (ids.some((id) => facts.get(id)?.state === 'error')) return 'error'; + if (!known.length) return 'unrun'; + return 'answered'; +} + async function renderDashboardByQuestion(slug, stage, host, live) { host.innerHTML = `Reading the questions…`; let questions; @@ -3495,8 +3595,6 @@ async function renderDashboardByQuestion(slug, stage, host, live) { const unmeasured = questions.length - measured.length; const asked = new Set(measured.flatMap((q) => q.analysis_ids || [])); - // Everything the stage measures, so what nobody asks for can be listed - // rather than lost. A catalog read, not a results read — it is the cheap one. let stageIds = []; try { const cat = await listAnalyses('repo', { intent: stage }); @@ -3505,44 +3603,106 @@ async function renderDashboardByQuestion(slug, stage, host, live) { const unasked = stageIds.filter((id) => !asked.has(id)); host.innerHTML = `Reading ${asked.size + unasked.length} measurements…`; - let facts = new Map(); + const facts = new Map(); + let answers; try { - const res = await getBulkFacts([slug], [...asked, ...unasked]); + // The facts, and — rule 1 — each question's ANSWER from the same layer + // the Evidence rail reads. Fetched together; the answers are per + // question and independent, so a slow one does not hold the rest. + const [res, envs, ctx] = await Promise.all([ + getBulkFacts([slug], [...asked, ...unasked]), + Promise.allSettled(measured.map((q) => getAnswer(slug, q.question))), + getContext('repo', slug).catch(() => ({})), + ]); + state.contextAnswers = ctx?.question_answers || {}; for (const f of (res.subjects || {})[slug] || []) facts.set(f.analysis_id, f); + answers = envs.map((e) => (e.status === 'fulfilled' ? e.value : null)); } catch (err) { if (live()) host.innerHTML = `The measurements could not be read: ${esc(err.message)}`; return; } if (!live()) return; - const shownWhole = new Map(); // analysis id -> question index that rendered it in full + const disputes = findDisputes(facts); + const disputeShownIn = new Map(); // key -> question index that rendered it + const shownWhole = new Map(); const sections = measured.map((q, qi) => { const ids = q.analysis_ids || []; + const env = answers[qi]; const declared = (q.checks || []).map((c) => String(c).split(':')); const checksFor = (id) => { const mine = declared.filter(([a]) => a === id).map(([, c]) => c).filter(Boolean); return mine.length ? new Set(mine) : null; }; + + // Rule 1: the answer sentence, relayed from the answer layer — the same + // one the Evidence rail shows. `lines.answer` is already HTML, escaped + // by readEnvelope branch by branch, so it is NOT escaped again here. + // Where the layer has nothing, say what was looked in — "0 of 3 + // reported" is a state. A human-answered question is a different case: + // its answer is what a person said, and the analyses under it are + // informants, not the answer. + const lines = env ? readEnvelope(q, env) : null; + const reported = ids.filter((id) => facts.get(id)?.state === 'measured').length; + const human = q.kind === 'human' ? (state.contextAnswers || {})[questionKey(q.question)] : null; + const answerLine = human && human.answer + ? `

${tnum(esc(human.answer))} + · answered ${esc(ago(human.answered_at))}

` + : q.kind === 'human' + ? `

needs a person to answer — the analyses below inform it, they do not decide it

` + : lines && lines.answer + ? `

${lines.answer}

` + : `

not answered — ${reported} of ${ids.length} analyses reported

`; + + // Rule 4: the section's disagreements, once, above the detail. + const mine = [...disputes].filter(([, rec]) => rec.some((x) => ids.includes(x.analysis))); + const disputeBlocks = mine.map(([key, rec]) => { + const prior = disputeShownIn.get(key); + if (prior !== undefined) { + return `
Same disagreement on ${esc(key.replace(/_/g, ' '))} as under + ${esc(measured[prior].question)}.
`; + } + disputeShownIn.set(key, qi); + return disputeHtml(key, rec, facts.get(rec[0].analysis)?.last_run_at); + }).join(''); + const body = ids.map((id) => { const checks = checksFor(id); if (!checks) { const prior = shownWhole.get(id); if (prior !== undefined && prior !== qi) { return `
${esc(id)} · shown in full under - ${esc(measured[prior].question)}
`; + ${esc(measured[prior].question)}`; } shownWhole.set(id, qi); } return analysisUnderQuestionHtml(facts.get(id), id, checks); }).join(''); - return `
-
${esc(q.question)}
+ + // Rule 3: provenance at section level. + const nMeasures = ids.reduce((n, id) => { + const v = facts.get(id)?.value; if (!v || typeof v !== 'object') return n; + return n + (Array.isArray(v.findings) ? v.findings.length : 0) + + Object.entries(v).filter(([k, x]) => k !== 'findings' && (typeof x === 'number' || typeof x === 'boolean')).length; + }, 0); + const st = sectionState(env, facts, ids); + return `
+
+ ${GLYPH[st] || '·'} +
+
${esc(q.question)}
+ ${answerLine} +
${ids.length} analys${ids.length === 1 ? 'is' : 'es'} · + ${nMeasures} measurements${lines && lines.lastRun ? ` · latest ${esc(ago(lines.lastRun))}` : ''}
+
+
${q.rationale ? `

${esc(q.rationale)}

` : ''} - ${body} + ${disputeBlocks} +
detail${body}
`; - }).join(''); + }); - const trailing = unasked.length ? `
+ const trailing = unasked.length ? `
Measured, but no question asks · ${unasked.length}

These analyses run at this stage and @@ -3552,12 +3712,24 @@ async function renderDashboardByQuestion(slug, stage, host, live) { ${unasked.map((id) => analysisUnderQuestionHtml(facts.get(id), id, null)).join('')}

` : ''; - host.innerHTML = `
+ // The anchor rail: five questions, five glyphs, jump to any. The same + // vocabulary as the matrix, and the reason a 2,300-pixel page can be + // seen whole. + const rail = measured.length > 1 ? `` : ''; + + host.innerHTML = `
${measured.length} question${measured.length === 1 ? '' : 's'} with measurements${ unmeasured ? ` · ${unmeasured} answered without one` : ''}${ - unasked.length ? ` · ${unasked.length} measured and unasked` : ''} + unasked.length ? ` · ${unasked.length} measured and unasked` : ''}${ + disputes.size ? ` · ${disputes.size} disagreement${disputes.size === 1 ? '' : 's'}` : ''} ${purposeLegendHtmlFor(questions)}
- ${sections || `

No question at this stage names an analysis.

`} + ${rail} + ${sections.join('') || `

No question at this stage names an analysis.

`} ${trailing}`; host.querySelectorAll('[data-measure]').forEach((n) => { @@ -3566,14 +3738,12 @@ async function renderDashboardByQuestion(slug, stage, host, live) { metric: n.dataset.metric || '', summary: n.dataset.summary || '', when: n.dataset.when || '', })); }); - // Work lists live under the Investigation frame — the matrix — not under a - // sub-tab of this resource. Promotion is a decision about a set. - host.querySelector('[data-goto-worklist]')?.addEventListener('click', () => { + host.querySelectorAll('[data-goto-worklist]').forEach((b) => b.addEventListener('click', () => { state.stage = 'investigation'; writeUrl(); renderIntentNav(); loadPane(); - }); + })); for (const n of host.querySelectorAll('[data-delta]')) { const [analysisId] = n.dataset.delta.split('|'); deltaFor(slug, analysisId).then((text) => { diff --git a/packages/resource-explorer/tests/test_init_schema_takes_no_write_lock_when_idle.py b/packages/resource-explorer/tests/test_init_schema_takes_no_write_lock_when_idle.py new file mode 100644 index 000000000..4527df67d --- /dev/null +++ b/packages/resource-explorer/tests/test_init_schema_takes_no_write_lock_when_idle.py @@ -0,0 +1,45 @@ +"""Opening the registry must not write when there is nothing to migrate. + +The 2026-09-11 annotation-type rename ran an unconditional UPDATE inside +_init_schema, before CREATE INDEX IF NOT EXISTS on the same table. Under +Postgres an UPDATE takes RowExclusiveLock even when it matches no rows, CREATE +INDEX wants ShareLock, and two processes opening the registry at once each +held the first while waiting for the second: a deadlock on every concurrent +open. Reproduced with sixteen parallel opens against the live registry — +14 of 16 deadlocked — and fixed by reading first and updating only when a +stale row exists. + +SQLite has no such lock, so this asserts the *shape* the fix relies on: a +fresh registry's init issues no UPDATE against that table. If someone later +adds another unconditional write to _init_schema, this is what fails. +""" +from __future__ import annotations + +from resource_explorer.registry import ProjectRegistry + + +def test_fresh_registry_init_issues_no_update_on_published_annotation_types(tmp_path, monkeypatch): + r = ProjectRegistry(db_path=str(tmp_path / "t.db")) + + seen: list[str] = [] + real_conn = r._conn + + class SpyCtx: + def __init__(self, ctx): self.ctx = ctx + def __enter__(self): + conn = self.ctx.__enter__() + real = conn.execute + def spy(sql, *a, **k): + seen.append(" ".join(str(sql).split())) + return real(sql, *a, **k) + conn.execute = spy + return conn + def __exit__(self, *a): return self.ctx.__exit__(*a) + + monkeypatch.setattr(r, "_conn", lambda: SpyCtx(real_conn())) + r._init_schema() + + updates = [s for s in seen if s.upper().startswith("UPDATE PROJECT_PUBLISHED_ANNOTATION_TYPES")] + assert updates == [], f"init wrote to project_published_annotation_types with nothing to migrate: {updates}" + # and the guard read DID run, so the migration is still present + assert any("SELECT 1 FROM project_published_annotation_types" in s for s in seen) From 90b80ec2e30e1893b0996441b104d6b9d61d4425 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Fri, 11 Sep 2026 15:08:51 -0500 Subject: [PATCH 03/37] =?UTF-8?q?RE=20experiment:=20run=207=20=E2=80=94=20?= =?UTF-8?q?the=20catalog=20caveat=20beside=20the=20evidence;=20best=20run-?= =?UTF-8?q?over-run=20movement=20so=20far?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compiler = re/compiler-catalog-caveats (2c9ca21), run from a worktree before its PR merged; judge held at v3, seed and repos unchanged. 312 rows, 0 errors, replay 156/156. Paired on 156 questions vs run 6: answers_question +0.13 (32 up / 15 down), supported +0.17, unsupported -0.06, cites +6 points, misread flat - every metric the right way, still inside the noise band on the answer score until reproduced. The caveat's own words reach the secrets answers ("against the gitleaks ruleset over the HEAD snapshot"). docling's CVE row is still a bare "Yes" under an honest headline and caveat, which settles the residual as the 8B answerer's; two one-variable options recorded. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Dan Wolfson --- .../docs/experiments/compiled-vs-rag.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/resource-explorer/docs/experiments/compiled-vs-rag.md b/packages/resource-explorer/docs/experiments/compiled-vs-rag.md index 638ebeda3..6a6b84dc3 100644 --- a/packages/resource-explorer/docs/experiments/compiled-vs-rag.md +++ b/packages/resource-explorer/docs/experiments/compiled-vs-rag.md @@ -80,6 +80,45 @@ Runs judged under different rubrics are never averaged together. ## Runs +### run7-20260911 — the catalog caveat beside the evidence (branch re/compiler-catalog-caveats, 2c9ca21) + +Same judge (v3), seed and repos as run 6; the compiler gained one thing: the matched question's +catalog caveat (the Rationale/Source column) placed inside the section it qualifies, under the +headline, or in the preamble when no packed section carries it. Run from a worktree of the branch +before its PR merged; the results apply to the merged code unchanged. +`data/experiments/compiled_vs_rag_run7/results.jsonl`. 312 rows, 0 errors, replayability 156 of +156. The caveat sat inside a section in 92 compiles and in the preamble in 61. + +| metric (v3) | run 7 compiled | run 7 rag | run 6 compiled | run 6 rag | +|---|---:|---:|---:|---:| +| answers_question (0–2) | **1.12** | 0.30 | 0.99 | 0.31 | +| supported_claims (mean) | **1.98** | 0.33 | 1.81 | 0.33 | +| unsupported_claims (mean) | 0.28 | 0.79 | 0.35 | 0.81 | +| misread_claims (mean) | 0.03 | 0.04 | 0.03 | 0.03 | +| cites_evidence | 67% | 5% | 61% | 6% | +| declines | 31% | 60% | 31% | 63% | + +Paired on all 156 questions (compiled, run 7 minus run 6): answers_question **+0.13** (32 up, 15 +down, 109 tied); supported_claims **+0.17** (37/25/94); unsupported −0.06 (15 up, 21 down, 120 +tied); cites +6 points; misread flat. This is the largest run-over-run movement in the series and +the first where every metric moves the right way with more rows up than down on the answer +score; it is still inside the protocol's ten-point noise band on answers_question and should be +reproduced before it is quoted as an effect. The RAG control is flat, as it should be. + +**Where the caveat shows.** The secrets question, whose caveat says the scan "never claims 'no +secrets' — only 'no matches against this ruleset, in this snapshot'", now answers with the +limit in it: kafka "46 secret matches … using a vendored gitleaks ruleset over the HEAD snapshot +of tracked files"; egeria "7 secret matches against the gitleaks ruleset". Those are the caveat's +own words reaching the answer. + +**Where it does not.** "Are there outstanding CVEs?" on docling — headline "not checked: 0 of 61", +caveat "DECLARED dependencies only, so a zero is 'none found in what we can see'" directly under +it — still gets "Yes, there are outstanding CVEs." (run 6: "Yes"). kafka answers correctly with its +coverage, egeria's real advisory gets a bare "Yes". Three compilers' worth of honest evidence has +not moved an 8B model off a one-word answer to a yes/no question, which settles that the residual +is the answering side. The two options stand: an instruction that a yes/no answer must carry the +evidence line it rests on, or a larger answering model. Either is a one-variable run. + ### run6-20260911 — coverage-first cve_scan headline (3f53e52); otherwise run 5 repeated Same compiler as run 5 except `_cve_scan_headline`'s wording; same judge (v3), same seed, text From 63f498e2ca146afecab9fd7bf35caeafc3fda8c0 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Fri, 11 Sep 2026 16:11:26 -0500 Subject: [PATCH 04/37] Analysis pane, finished: the limit stays, the history moves, and the answer reads as one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repo Handoff items 1 and 2, from the Dashboard Round Three review. Every question carried three to five lines of accent prose between its answer and the next question — and with everything else collapsed, that was the largest text on the pane and the only thing on it not in the product's voice. The prose was the catalog's Rationale/Source column, carried to the screen two rounds ago, and it mixed two things that need different homes. The LIMIT — what this answer does and does not cover, present tense, for a reader deciding how far to trust it — stays in Rationale/Source and is the caption under the answer, in the accent. The HISTORY — what used to be wrong, which analysis landed when, what the generator could not parse — moves to a new Catalog History column, carried through generator, YAML, reader and route, and shown behind a muted "catalog history" disclosure. Sixteen Analysis-stage rows were split, each keeping only what it already said, in the tense it belongs in; nothing was added. The accent now goes to the limit alone. Two tests pin the split: no Analysis limit reads as a changelog, and history is carried only where it was authored. The composition bug: several analyses' sentences joined with a space read as one broken sentence — "all 3 checks pass one contributor writes most of the code". Joined on " · " now. The separator makes the boundaries visible without deciding how the claims combine, which is the judgement still owed. The misleading tick: "Is there a current, published, security analysis?" showed ✓ over prose that said nothing detects one. The catalog calls that question `mixed`; a mixed or partial question whose analyses reported is now ◐ in the accent — attention, not completion — rather than ✓. Generic, not special-cased. History shows its changes, not its rows: the measurement popup leads with the transitions — "57 08-07 → 59 08-24 → 68 09-01 · 30 runs, 2 changes" — and keeps every run one click on. Thirty rows of which twenty-eight were identical hid the two steps that mattered. And the smaller things: the summary line no longer offers two numbers that cannot partition one set ("27 questions at this stage · 16 measured · 11 answered another way"); the "was 2 20d ago" delta is gone from finding rows, where a previous count under a pass/fail is not a verdict; zero counts are dropped under an analysis whose state is nothing-found, since its sentence already said it; the unasked analyses show their sentence with the ledger behind a disclosure; and separation — a hairline with air above every question, the glyph in the margin, weight in the order answer, question, meta. 4,288px at 1280 wide, from 4,811. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- .../docs/dr-egeria/resource_questions.csv | 106 ++++++------- .../configdata/question_catalog.yaml | 139 ++++++++++++------ .../surveyors/question_catalog_reader.py | 7 + .../resource_explorer/web/routes/projects.py | 3 + .../resource_explorer/web/static/next/app.js | 83 ++++++++--- .../scripts/csv_to_question_catalog_yaml.py | 11 +- .../tests/test_scouting_questions_route.py | 46 +++++- 7 files changed, 275 insertions(+), 120 deletions(-) diff --git a/packages/resource-explorer/docs/dr-egeria/resource_questions.csv b/packages/resource-explorer/docs/dr-egeria/resource_questions.csv index 56c58dc2d..af2bb9c7b 100644 --- a/packages/resource-explorer/docs/dr-egeria/resource_questions.csv +++ b/packages/resource-explorer/docs/dr-egeria/resource_questions.csv @@ -1,53 +1,53 @@ -Question,Funnel Stage,Why is this important?,Rationale/Source,Answering Analysis,Answering Mechanism,Financial,Governance,Steward,Data Owner,Consumer,App/AI Builder,Privacy,Community,Data Expert,Security,Architecture,Admin,Purposes -Is this repository actively maintained?,Scouting,I don't want to start using something that is a dead-end burden.,"Recency/frequency signal — last-pushed date, commit cadence — answerable from GitHub API metadata alone (RE's HealthSurveyor), no zipball needed.",repository_health + foss_scorecard [checks: foss_scorecard:maintained],Git Statistics + Code Analysis,,,X,,X,,,,X,,,,Select;Maintain -What does this repository do?,Scouting,Need to understand it to use it appropriately.,"Purpose/description — from the repo's own GitHub description field, no deeper analysis required.",N/A — direct field (Project.description) This might also be a summary of the top level README.,Direct Field,,,,,X,,,,X,,X,,Explore;Select;Learn -Who maintains this repository?,Scouting,Are they reputable?,"repository_health alone answers 'is it maintained', not 'by whom' — the two questions returned the same text until chaoss_metrics, built on project_commits, gave this one its own source.",repository_health + chaoss_metrics (contributor identities and concentration),Git Statistics,,,X,X,,,,,,X,,,Explore;Select -How widely adopted and active is the community around this repository?,Scouting,"Reduces adoption risk, provides a health proxy, and ensures community support and responsiveness.","Stars are attention, not community. community_support reports the dimensions separately and names the weakest rather than averaging, so 'popular but unsupported' stays visible instead of averaging out.","repository_health + community_support (attention, participation and channels as separate dimensions, weakest named) + chaoss_metrics",Git Statistics + Code Analysis,,,,,X,,,X,,,,,Explore;Select -Has this repository already been catalogued in Egeria and when?,Discovery,Has someone already done this analysis? What did they learn?,"""Known inventory"" check — is there already a SourceControlLibrary asset for this repo, independent of whether it's registered in Resource Explorer's own local registry. Ties to the Scouting ""shop for data"" search work.","N/A — direct field (is_published/egeria_asset_guid); GAP: known-inventory search across the Egeria catalog itself isn't built (Scouting's ""shop for data"" Track 1)",Egeria Queries,,X,X,,,,,,,,,,Explore;Maintain -Are there outstanding CVEs?,Analysis,Understand the risk.,"Closed 2026-08-26 by cve_scan, which queries OSV.dev for advisories against the dependencies manifest_parse recorded. Reports coverage as prominently as the count — DECLARED dependencies only, so a zero is 'none found in what we can see', not 'none exist'.",cve_scan,External Service Query,,X,,,,,,,,X,,,Select;Assess;Certify;Maintain -Is there any existing use within our organization?,Discovery,"Other experts, reduces risk if successful. Dependencies already in place, etc.",Answerable locally from RE's own registry (cross-reference against already-registered repos/groups) — no external API needed.,N/A — RE registry query (already-registered repos/groups) - probably a set of egeria queries.,Local Registry Query + Egeria Queries,,,,,X,X,X,X,X,X,X,X,Explore;Select -Any known feedback?,Discovery,"Other experts, reduces risk if positive.",RE's Curate intent already holds resource_feedback for cataloged resources.,N/A — direct field (resource_feedback table) - result from Egeriq queries/report,Local Registry Query + Egeria Queries,,,,,X,X,,X,X,X,X,X,Explore;Select -Does it replace or extend something we already have?,Discovery,Duplication can be costly - needs to be factored in.,"Answerable locally from RE's own registry/groups, same as ""existing use within our organization.""",N/A — RE registry query (already-registered repos/groups) - might need RAG support for this.,Local Registry Query + RAG Queries,X,,,,,,,,X,,X,,Select -What are similar repos/projects? How does this differ?,Analysis,Helping me choose.,"Needs similarity search over content (RAG/embeddings) — real gap, not built.",GAP: similarity search over pgvector embeddings (not built) - Agent over RAG ,Agent-Based Analysis,,,,,,,,X,,X,,X,Select -Are there any restrictions for use?,Analysis,Is this appropriate?,License field is cheaply available from the GitHub API — deeper copyleft-risk categorization is Assessment-tier (see the license-specifics row below).,"N/A — direct field (GitHub license field) - Also Agent over RAG? Direct field (GitHub license field) for a standard license (e.g. Apache 2.0) — the answer for those is itself usually just ""citation/attribution required"". A non-standard license needs the actual license text interpreted, which is where Agent-Based Analysis comes in instead of a direct-field read.",Direct Field (standard license) + Agent-Based Analysis (non-standard license interpretation),,,,,,,,,,X,X,X,Select;Certify -What dependencies does this require?,Analysis,Influences deployment cost and exposes additional transitive risks.,DependencySurveyor exists (Analysis-tier) — dependency list per ecosystem.,dependency_analysis ,Code Analysis,,,,,X,,,,X,X,,X,Select;Assess;Deploy -Do we already support these dependencies?,Analysis,Reduces risk.,"Needs the org's own tech inventory — human-supplied cross-reference, not resource-derivable.","N/A — human-supplied via Enrichment Context form - may also be a new Agent over RAG content. Cross-reference against Egeria's catalog of already-supported dependencies, validated/completed by human input — likely needs a per-dependency answer, not one blanket answer for the whole question.",Egeria Queries + Human-Supplied,,,,,,,,,,X,X,X,Select;Deploy -What deployment styles does this support?,Analysis,Need to understand if we can deploy it?,"Presence of Dockerfile/compose/Helm chart is now a cheap Discovery-tier signal (repo_conventions' deployment_docker check); actual deployment *instructions* still need content read (Dockerfile, deployment docs) — RAG-tier, real gap.",MIXED: repo_conventions (deployment_docker presence signal); GAP: RAG content read for deployment instructions [checks: repo_conventions:deployment_docker],Code Analysis + RAG Queries,,,,,,,,,,X,X,X,Select;Deploy -Do we know what the cost to run it is?,Analysis,Cost is part of the decision process.,Human-supplied/derived — not resource-derivable.,N/A — human-supplied via Enrichment Context form = may also be Agent over RAG content.,Human-Supplied,X,,,,,,,,,,,X,Select;Deploy -Is there a validation / deployment test for it?,Analysis,How do we know if it is working properly once deployed?,ci_quality checks whether CI workflows actually run tests (not just exist); repo_conventions' deployment_docker adds deployment-evidence signal — together a much better fit than CI-config-presence alone; a real deployment *validation* test is still a gap.,"MIXED: ci_quality (CI actually runs tests) + repo_conventions (deployment_docker evidence); GAP: real deployment-validation test - May be RAG queries? ci_quality/repo_conventions give structured signals; an Agent examining the repo's actual file/directory structure (test dirs, validation scripts) can find hints beyond those fixed checks. [checks: ci_quality:ci_runs_tests, repo_conventions:deployment_docker]",Code Analysis + Agent-Based Analysis,,,,,,,,,,X,,X,Assess;Deploy -What is the upgrade process?,Analysis,Is it deployable and maintainable? Are upgrades disruptive?,"Needs content read (CHANGELOG, release notes) — RAG-tier, real gap.",GAP: RAG content read (CHANGELOG/release notes) — no structured surveyor,RAG Queries,,,,,X,,,,X,,X,X,Maintain;Deploy -Does it fit into our monitoring infrastructure?,Analysis/Enrichment,"Cost, Skills and manageability.",Org-internal cross-reference — human-supplied.,N/A — human-supplied via Enrichment Context form - We may be able to use targeted Egeria queries first - augmented with enrichment.,Egeria Queries + Human-Supplied,,,,X,X,,,,,,X,X,Deploy;Maintain -Does it fit into our security infrastructure?,Analysis/Enrichment,Will it be allowed in? What are the risks?,"Org-internal cross-reference, informed by Assessment's security findings.","N/A — human-supplied via Enrichment Context form, informed by security_scan findings and Egeria queries.",Code Analysis + Egeria Queries + Human-Supplied,X,,,X,X,X,,,,X,X,X,Assess;Deploy -Is there any reliability or availability aspects?,Analysis,Is it production ready?,"Trend/uptime signal, if available — Understanding-tier chart, or Analysis if content-derived (SLA docs).","N/A — Understanding trend chart (stats.py, project_stats history); GAP if SLA-doc content read is needed instead. May require RAG search.",Trend Chart,,,,,,,,,,X,X,X,Select;Maintain -What kinds of integrations does it support?,Analysis,Can we connect it to our ecosystem?,interface_surface answers the structural half — which interfaces exist and whether they are documented. Which named third-party systems it integrates with still needs a content read and has no surveyor.,"PARTIAL: interface_surface (what can be talked to, and whether the contract is written down — from the file inventory and declared dependencies); GAP remains for the prose read of integration guides",Code Analysis + RAG Queries,,,,,X,,,,X,X,X,X,Select;Deploy -Does this fit within or need to extend our existing infrastructure estate?,Analysis/Enrichment,Impact on existing architecture and operations.,Org-internal cross-reference — human-supplied.,N/A — human-supplied via Enrichment Context form with as fallback from Egeria queries.,Human-Supplied + Egeria Queries,,,,,X,,,,,X,X,X,Select;Deploy -How mature is it?,Assessment,Stability vs innovation trade-off.,"Direct lifecycle-stage classification (nascent/emerging/established/mature) from repo_created_at, CHAOSS-informed (the maturity analysis) — a more direct answer than the age/release-cadence trend chart alone, which remains a secondary Understanding-tier view of the same underlying signal.","MIXED: maturity (direct classification) + Understanding trend chart (stats.py, project_stats history) [checks: maturity:project_maturity]",Code Analysis,,,,,,,,X,X,,X,X,Select;Assess -Do we have the skills to support its use?,Analysis/Enrichment,Operational readiness and skill gaps.,Purely human-supplied — never resource-derivable.,N/A — human-supplied via Enrichment Context form - Egeria queries as well,Human-Supplied + Egeria Queries,,,,,,,,,,X,X,X,Select;Learn -How well documented is it?,Analysis,Ease of adoption and maintenance.,"documentation_coverage checks root README/CHANGELOG/CONTRIBUTING presence + quality label; repo_conventions' doc_breadth adds a whole-tree density signal (README count, docs/ folder, .md/.txt count) that the root-only check misses.","MIXED: documentation_coverage + repo_conventions (doc_breadth) perhaps also an Agent? documentation_coverage/repo_conventions give structured presence signals; an Agent reviewing the whole set (READMEs in every folder, a separate docs directory, tutorials, etc.) can judge documentation quality more holistically than presence-checks alone. [checks: repo_conventions:doc_breadth]",Code Analysis + Agent-Based Analysis,,,,,X,,,X,X,,X,X,Select;Learn;Share -How is it supported?,Analysis,Enterprise fallback and support options.,Mixed: community responsiveness (Scouting-cheap) + documentation quality (Assessment) + any formal support docs (Analysis/RAG).,MIXED: repository_health (community responsiveness) + documentation_coverage + sla_content (published support commitments),Git Statistics + Code Analysis,X,,,,X,,,,X,,X,X,Select;Maintain -"Is there a current, published, security analysis?",Analysis,Third-party audit and security posture.,"The GAP note here named OpenSSF Scorecard and CHAOSS as candidate tooling; both landed 2026-08-26 as foss_scorecard and chaoss_metrics, and cii_badge reads the real published badge. Still MIXED rather than answered: a published third-party audit is a different artifact from any of these, and nothing detects one.","MIXED: cii_badge (the real OpenSSF Best Practices badge, read from bestpractices.dev) + foss_scorecard (OpenSSF-Scorecard-shaped checks computed locally, NOT the upstream tool's own run) + chaoss_metrics; security_scan + security_features + repo_conventions for hygiene signals",External Service Query + Code Analysis,,X,,,,,,,,X,X,X,Assess;Certify -Does it fit into our governance frameworks?,Analysis/Enrichment,Compliance and regulatory alignment.,"Org policy cross-reference (human-supplied), plus Curate's zone/catalog membership once published.","N/A — human-supplied via Enrichment Context form, plus Curate zone/catalog membership (egeria_publish), plus Egeria queries.",Human-Supplied + Egeria Queries,,X,,,,,,,,X,X,X,Certify;Share -"What explicit license does the repository use, and are there non-standard or copyleft terms?",Assessment,"Identifies strict copyleft (GPL/AGPL), source-available shifts (BSL/SSPL), or dual-licensing risks before code integration.","GitHub API license field gives the SPDX id cheaply (Scouting); license_classification (Assessment) categorizes it into a risk tier (permissive/weak copyleft/strong copyleft/source-available/unknown) — candidate reference tools for deeper provenance: FOSSology (Linux Foundation compliance workspace), LicenseFinder, ClearlyDefined (crowdsourced licensing/provenance data), Mend, Black Duck. SPDX (ISO/IEC 5962) is the standard schema this conforms to.",license_classification [checks: license_classification:license_risk_tier],Code Analysis,X,,,X,,X,,,,X,,,Select;Certify -Is intellectual property (IP) provenance managed via CLA or DCO?,Analysis,Protects the organization from copyright infringement claims from unauthorized third-party contributions.,"contribution_provenance detects CLA/DCO signals — DCO sign-off in commit trailers, CLA bot configuration, and CONTRIBUTING.md statements — and reports which mechanism is in use rather than a yes/no. Was marked GAP until 2026-09-02; the analysis shipped 2026-09-01 and the row was never updated.",contribution_provenance,Code Analysis,X,X,X,X,,,,,,X,,,Select;Certify -"Does the software contain telemetry, phone-home mechanisms, or external metrics tracking?",Analysis,"Prevents unauthorized leakage of internal network metadata, system details, or usage patterns.","telemetry_scan matches known telemetry/analytics SDK imports and endpoints against source, and separately checks whether any match is disclosed in the repo's own docs — an undisclosed match is the finding, not the presence of telemetry. Was marked GAP until 2026-09-02; the analysis shipped 2026-09-01.",telemetry_scan,Code Analysis,,,,,,,X,,,X,X,X,Assess;Certify -"For AI/ML assets, what licensing or usage constraints apply to models, weights, or datasets?",Analysis,"Open-source software licenses (e.g., Apache 2.0) often differ from model weights (e.g., OpenRAIL, Llama) or training data constraints.","Needs model-card/dataset-file detection — real gap, AI Governance tooling category.",GAP: model-card/dataset-file detection (AI Governance tooling category),Gap,,X,,,,X,X,,X,,,,Select;Certify -"How does the repository handle secrets, credentials, and sensitive configurations?",Analysis,Prevents hardcoded secrets or unencrypted credential passing in containerized and automated deployment environments.,"secret_scan runs a vendored gitleaks ruleset over the HEAD snapshot of tracked files, reporting which ruleset and commit produced the result. Never claims 'no secrets' — only 'no matches against this ruleset, in this snapshot' — and says nothing about git history, where a removed secret still lives. Was marked GAP until 2026-09-02; the analysis shipped 2026-09-01.",secret_scan,Code Analysis,,,,,,,,,,X,X,X,Assess;Certify -Does the repository publish a clear process for reporting security vulnerabilities?,Analysis,Knowing how to responsibly disclose a finding — and whether maintainers take security seriously — matters before we rely on this.,"repo_conventions reads SECURITY.md's CONTENT (is there an actual disclosure process, not just a file); security_scan's hygiene check reports the file's presence. The row read 'repo_conventions - RAG read?', which the generator could not parse into a kind, so the question was unanswerable despite being correctly wired.",repo_conventions + security_scan [checks: repo_conventions:security_policy_content],Code Analysis,,X,,,,,,,,X,,,Certify -Does the repository have automated build tooling in place?,Analysis,Independent of CI wiring — a Makefile/build.gradle/pyproject build-system table means it can be built locally even if CI is absent or broken.,"repo_conventions detects build tooling configuration; ci_quality reports whether CI actually runs, tests and gates. Same parser problem as above — the trailing '- RAG read?' left the row as kind=unknown and therefore unanswerable.",repo_conventions + ci_quality [checks: repo_conventions:automated_build],Code Analysis,,,,,,X,,,,,X,,Certify;Deploy -Is this repository already self-described for an enterprise catalog (e.g. Backstage catalog-info.yaml)?,Assessment,"A strong, cheap signal that this repo is already integrated into some enterprise inventory, independent of which catalog it names.",repo_conventions detects catalog-info.yaml and equivalent self-description files. Same parser problem — the row was wired correctly and phrased in a way the generator read as unknown.,repo_conventions [checks: repo_conventions:catalog_info],Code Analysis,,X,X,,,,,,,,X,,Share -"Has this resource already been surveyed at any tier, and what did earlier signals reveal?",Discovery,Avoid re-deriving what's already known; ground the decision in prior signal instead of starting cold.,"Direct field — last_surveyed_at, is_published (egeria_asset_guid), and any locally-cached findings/metrics history already surfaced by the resource's own analysis cards.",N/A — direct field (last_surveyed_at/egeria_asset_guid; scoped analysis-history queries),Direct Field,,,X,,,,,,X,,X,,Explore;Maintain -Which Survey Definition should I run — a quick coarse check or the full deep survey?,Discovery,"Running the wrong tier wastes time (full survey when a coarse check would do) or leaves real gaps (coarse-only when depth was actually needed) — the choice should be informed, not default.",Survey Definitions carry their own step lists/descriptions and survey_kind tag; the Discovery candidates list surfaces exactly this per-step breakdown before committing to run one.,"N/A — direct field (Survey Definition candidates list, step/survey_kind detail)",Direct Field,,,X,,,,,,X,,X,X,Explore -"Based on what's already known, is this worth investigating further, or should it be deprioritized?",Discovery,"Discovery-tier signal (license risk, maturity, conventions) is exactly the point a go/no-go call should get made and recorded, not lost.","Disposition control (Track/Investigate/Recommend/Abandon/Ignore) plus history — the same repo_dispositions state Scouting already exposes, now reachable directly from Discovery too.",N/A — direct field (repo_dispositions) - there is some heuristic we might need to compute to recommend a disposition,Direct Field,,X,X,X,,,,,,,,,Explore;Select -How much has changed since the last time this was surveyed — is it worth re-running now?,Automate,Re-surveying on a blind schedule wastes cycles; knowing whether anything material actually changed first is the real Discovery-tier judgment.,"notification_detector.py's latest-two-runs comparison, surfaced via an Automate subscription once a schedule exists for the analysis — real today, but only reachable once that subscription is set up.",N/A — Automate subscription (notification_detector's change comparison),Automate Change Detection,,,X,,,,,,X,,,,Maintain -"Is there a Survey Definition authored for this resource's technology type at all, or is that a catalog gap?",Discovery,"A resource type with no Survey Definition authored yet is itself a real, actionable finding — a catalog gap someone should close — not a silent empty list.",Direct field — the candidates list itself; an empty result for a technology type with real registered resources is the signal.,"N/A — direct field (Survey Definition candidates list, empty-result case)",Direct Field,,X,,,,,,,,,,X,Explore;Share -"What kind of thing is this repository — a library, an application, a tool, or samples?",Discovery,"The same signal means different things depending on what the repo is; a tutorial with no tests is fine, a library with none is not.","repo_classification exists and nothing asked for it. It also decides whether architecture recovery is worth running, so it gates later tiers.",repo_classification,Code Analysis,,,,,X,,,,X,,X,,Explore;Select -"What components exist in this repository, and what kind is each?",Discovery,"I need to know what I would be taking on before committing to it, and whether its own documentation matches what the code actually does.","architecture_recovery, architecture_summary and architecture_doc_lens all exist; no question referenced any of them. The doc lens specifically compares documented architecture against recovered architecture.",architecture_recovery + architecture_summary + architecture_doc_lens,Code Analysis,,,,,,X,,,X,,X,,Explore;Learn;Select -How do its components relate to each other?,Discovery,"I need to know what I would be taking on before committing to it, and whether its own documentation matches what the code actually does.","Split from the combined 'internal architecture' question 2026-09-08 (Dan: is a separate question worth splitting out; chat-facts rendering cannot flatten a component graph into bullet text at any real scale, so this half needs its own answer, not a share of the other one's). architecture_diagram is the Mermaid diagram + caption rendered from architecture_recovery/architecture_summary at survey time -- previously computed and persisted, never read back by anything.",architecture_diagram,Code Analysis,,,,,,X,,,X,,X,,Explore;Learn;Select -What languages and file types make up this repository?,Scouting,Language mix is the cheapest signal of whether this is something we can maintain in-house.,language_file_classification is one of the oldest analyses here and no question referenced it.,language_file_classification,Code Analysis,,,,,X,,,,X,,X,,Explore;Select -"What data files does it ship, and what shape are they?",Analysis,Bundled data can carry licensing and privacy obligations that the repo's own licence does not cover.,data_file_profiling exists and no question referenced it.,data_file_profiling,Code Analysis,,,,X,,,X,,X,,,,Explore;Assess -What APIs and code symbols does it expose to callers?,Analysis,"The published surface is what we would actually be coupling to, and what breaks on upgrade.",api_structure and interface_surface both exist; neither was referenced by a question.,api_structure + interface_surface,Code Analysis,,,,,X,X,,,X,,X,,Select;Deploy;Learn -How does it score against OpenSSF Scorecard-style criteria?,Assessment,"A named, comparable rubric is what lets me weigh several candidates against each other rather than judging each on its own terms.","foss_scorecard computes Scorecard-shaped checks from data already collected — it is NOT the upstream OpenSSF tool's own run, and a check it cannot evaluate is reported as not-established rather than as a failure.",foss_scorecard,Code Analysis,,X,X,,,,,,,X,,,Assess;Certify;Select -"Does it hold an OpenSSF Best Practices (CII) badge, and how current is the self-assessment behind it?",Assessment,A badge is a public claim by the project about itself; its age tells me how much the claim is still worth.,"cii_badge reads the real badge from bestpractices.dev rather than estimating one, and reports level together with the age of the self-assessment. No question referenced it.",cii_badge,External Service Query,,X,X,,,,,X,,X,,,Assess;Certify;Attest -How concentrated is authorship — would the project survive losing its top contributors?,Assessment,A healthy commit rate produced by one person is a different risk from the same rate produced by twenty.,"chaoss_metrics reports the elephant factor and related CHAOSS metrics on their own terms, never averaged into a score. No question referenced it.",chaoss_metrics,Git Statistics,,X,X,,,,,X,,,,,Assess;Maintain -How much code is there? How complex?,Assessment,Gives an estimate on cost to maintain.,"Both halves are answerable as of 2026-09-02. SIZE: language_file_classification carries a per-language line census — code, comment, docstring and blank — written at ingestion and at Coarse Profile refresh (pipeline.py::_record_line_census). Markdown, JSON, HTML, CSS, SQL and XML are counted as text and excluded from the code total by construction; project_stats.ingestion_lines_of_code, which counted every newline in every text file and reported 1,118,195 against ~156k real code lines on egeria-python, is retired and must not be cited. COMPLEXITY: api_structure carries the per-language max/mean it always computed, python and java only — go and javascript store 0 for every symbol because their extractors never compute it, so they are excluded and named rather than averaged in (0.32 vs 2.69 on milvus). A repo with no census reports no line counts rather than zero: not counted is not the same as no code.",language_file_classification + api_structure,Code Analysis,,,,,,x,,,,x,x,x,Assess;Maintain -What are the public interfaces?,Discovery,How do we use this - does it look like it does what we want?,"interface_surface detects which interface styles a repo exposes (http_api, grpc, graphql, messaging, soap, cli) and whether a contract is actually published, versus merely implied by a dependency. Discovery altitude: 'is there a published contract' rather than 'what symbols exist', which is the Analysis-stage question 'What APIs and code symbols does it expose to callers?' (api_structure + interface_surface).",interface_surface,Code Analysis,,,,,,x,x,,,x,x,x,Assess;Deploy;Explore;Learn +Question,Funnel Stage,Why is this important?,Rationale/Source,Answering Analysis,Answering Mechanism,Financial,Governance,Steward,Data Owner,Consumer,App/AI Builder,Privacy,Community,Data Expert,Security,Architecture,Admin,Purposes,Catalog History +Is this repository actively maintained?,Scouting,I don't want to start using something that is a dead-end burden.,"Recency/frequency signal — last-pushed date, commit cadence — answerable from GitHub API metadata alone (RE's HealthSurveyor), no zipball needed.",repository_health + foss_scorecard [checks: foss_scorecard:maintained],Git Statistics + Code Analysis,,,X,,X,,,,X,,,,Select;Maintain, +What does this repository do?,Scouting,Need to understand it to use it appropriately.,"Purpose/description — from the repo's own GitHub description field, no deeper analysis required.",N/A — direct field (Project.description) This might also be a summary of the top level README.,Direct Field,,,,,X,,,,X,,X,,Explore;Select;Learn, +Who maintains this repository?,Scouting,Are they reputable?,"repository_health alone answers 'is it maintained', not 'by whom' — the two questions returned the same text until chaoss_metrics, built on project_commits, gave this one its own source.",repository_health + chaoss_metrics (contributor identities and concentration),Git Statistics,,,X,X,,,,,,X,,,Explore;Select, +How widely adopted and active is the community around this repository?,Scouting,"Reduces adoption risk, provides a health proxy, and ensures community support and responsiveness.","Stars are attention, not community. community_support reports the dimensions separately and names the weakest rather than averaging, so 'popular but unsupported' stays visible instead of averaging out.","repository_health + community_support (attention, participation and channels as separate dimensions, weakest named) + chaoss_metrics",Git Statistics + Code Analysis,,,,,X,,,X,,,,,Explore;Select, +Has this repository already been catalogued in Egeria and when?,Discovery,Has someone already done this analysis? What did they learn?,"""Known inventory"" check — is there already a SourceControlLibrary asset for this repo, independent of whether it's registered in Resource Explorer's own local registry. Ties to the Scouting ""shop for data"" search work.","N/A — direct field (is_published/egeria_asset_guid); GAP: known-inventory search across the Egeria catalog itself isn't built (Scouting's ""shop for data"" Track 1)",Egeria Queries,,X,X,,,,,,,,,,Explore;Maintain, +Are there outstanding CVEs?,Analysis,Understand the risk.,"Covers advisories against DECLARED dependencies only, queried from OSV.dev, with coverage reported as prominently as the count — so a zero is 'none found in what we can see', not 'none exist'.",cve_scan,External Service Query,,X,,,,,,,,X,,,Select;Assess;Certify;Maintain,"Closed 2026-08-26 by cve_scan, reading the dependencies manifest_parse recorded." +Is there any existing use within our organization?,Discovery,"Other experts, reduces risk if successful. Dependencies already in place, etc.",Answerable locally from RE's own registry (cross-reference against already-registered repos/groups) — no external API needed.,N/A — RE registry query (already-registered repos/groups) - probably a set of egeria queries.,Local Registry Query + Egeria Queries,,,,,X,X,X,X,X,X,X,X,Explore;Select, +Any known feedback?,Discovery,"Other experts, reduces risk if positive.",RE's Curate intent already holds resource_feedback for cataloged resources.,N/A — direct field (resource_feedback table) - result from Egeriq queries/report,Local Registry Query + Egeria Queries,,,,,X,X,,X,X,X,X,X,Explore;Select, +Does it replace or extend something we already have?,Discovery,Duplication can be costly - needs to be factored in.,"Answerable locally from RE's own registry/groups, same as ""existing use within our organization.""",N/A — RE registry query (already-registered repos/groups) - might need RAG support for this.,Local Registry Query + RAG Queries,X,,,,,,,,X,,X,,Select, +What are similar repos/projects? How does this differ?,Analysis,Helping me choose.,"Needs similarity search over content (RAG/embeddings) — real gap, not built.",GAP: similarity search over pgvector embeddings (not built) - Agent over RAG ,Agent-Based Analysis,,,,,,,,X,,X,,X,Select, +Are there any restrictions for use?,Analysis,Is this appropriate?,License field is cheaply available from the GitHub API — deeper copyleft-risk categorization is Assessment-tier (see the license-specifics row below).,"N/A — direct field (GitHub license field) - Also Agent over RAG? Direct field (GitHub license field) for a standard license (e.g. Apache 2.0) — the answer for those is itself usually just ""citation/attribution required"". A non-standard license needs the actual license text interpreted, which is where Agent-Based Analysis comes in instead of a direct-field read.",Direct Field (standard license) + Agent-Based Analysis (non-standard license interpretation),,,,,,,,,,X,X,X,Select;Certify, +What dependencies does this require?,Analysis,Influences deployment cost and exposes additional transitive risks.,"The declared dependency list, per ecosystem, from the manifests present.",dependency_analysis ,Code Analysis,,,,,X,,,,X,X,,X,Select;Assess;Deploy,Answered by DependencySurveyor (Analysis tier). +Do we already support these dependencies?,Analysis,Reduces risk.,"Needs the org's own tech inventory — human-supplied cross-reference, not resource-derivable.","N/A — human-supplied via Enrichment Context form - may also be a new Agent over RAG content. Cross-reference against Egeria's catalog of already-supported dependencies, validated/completed by human input — likely needs a per-dependency answer, not one blanket answer for the whole question.",Egeria Queries + Human-Supplied,,,,,,,,,,X,X,X,Select;Deploy, +What deployment styles does this support?,Analysis,Need to understand if we can deploy it?,"Covers whether a Dockerfile, compose file or Helm chart is present. What the deployment instructions actually say is not read.",MIXED: repo_conventions (deployment_docker presence signal); GAP: RAG content read for deployment instructions [checks: repo_conventions:deployment_docker],Code Analysis + RAG Queries,,,,,,,,,,X,X,X,Select;Deploy,Presence became a cheap Discovery-tier signal through repo_conventions' deployment_docker check; a content read of Dockerfiles and deployment docs remains a RAG-tier gap. +Do we know what the cost to run it is?,Analysis,Cost is part of the decision process.,Human-supplied/derived — not resource-derivable.,N/A — human-supplied via Enrichment Context form = may also be Agent over RAG content.,Human-Supplied,X,,,,,,,,,,,X,Select;Deploy, +Is there a validation / deployment test for it?,Analysis,How do we know if it is working properly once deployed?,Covers whether CI runs tests and whether deployment evidence exists; a real deployment validation test is not detected.,"MIXED: ci_quality (CI actually runs tests) + repo_conventions (deployment_docker evidence); GAP: real deployment-validation test - May be RAG queries? ci_quality/repo_conventions give structured signals; an Agent examining the repo's actual file/directory structure (test dirs, validation scripts) can find hints beyond those fixed checks. [checks: ci_quality:ci_runs_tests, repo_conventions:deployment_docker]",Code Analysis + Agent-Based Analysis,,,,,,,,,,X,,X,Assess;Deploy,ci_quality replaced a CI-config-presence check with one that reads whether workflows run tests; repo_conventions' deployment_docker added the deployment-evidence signal. +What is the upgrade process?,Analysis,Is it deployable and maintainable? Are upgrades disruptive?,"Needs content read (CHANGELOG, release notes) — RAG-tier, real gap.",GAP: RAG content read (CHANGELOG/release notes) — no structured surveyor,RAG Queries,,,,,X,,,,X,,X,X,Maintain;Deploy, +Does it fit into our monitoring infrastructure?,Analysis/Enrichment,"Cost, Skills and manageability.",Org-internal cross-reference — human-supplied.,N/A — human-supplied via Enrichment Context form - We may be able to use targeted Egeria queries first - augmented with enrichment.,Egeria Queries + Human-Supplied,,,,X,X,,,,,,X,X,Deploy;Maintain, +Does it fit into our security infrastructure?,Analysis/Enrichment,Will it be allowed in? What are the risks?,"An organisation-internal cross-reference: the security findings inform it, a person answers it.","N/A — human-supplied via Enrichment Context form, informed by security_scan findings and Egeria queries.",Code Analysis + Egeria Queries + Human-Supplied,X,,,X,X,X,,,,X,X,X,Assess;Deploy, +Is there any reliability or availability aspects?,Analysis,Is it production ready?,"Trend/uptime signal, if available — Understanding-tier chart, or Analysis if content-derived (SLA docs).","N/A — Understanding trend chart (stats.py, project_stats history); GAP if SLA-doc content read is needed instead. May require RAG search.",Trend Chart,,,,,,,,,,X,X,X,Select;Maintain, +What kinds of integrations does it support?,Analysis,Can we connect it to our ecosystem?,Covers which interfaces exist and whether they are documented. Which named third-party systems it integrates with is not read.,"PARTIAL: interface_surface (what can be talked to, and whether the contract is written down — from the file inventory and declared dependencies); GAP remains for the prose read of integration guides",Code Analysis + RAG Queries,,,,,X,,,,X,X,X,X,Select;Deploy,interface_surface answers the structural half; the content read for named systems has no surveyor. +Does this fit within or need to extend our existing infrastructure estate?,Analysis/Enrichment,Impact on existing architecture and operations.,Org-internal cross-reference — human-supplied.,N/A — human-supplied via Enrichment Context form with as fallback from Egeria queries.,Human-Supplied + Egeria Queries,,,,,X,,,,,X,X,X,Select;Deploy, +How mature is it?,Assessment,Stability vs innovation trade-off.,"Direct lifecycle-stage classification (nascent/emerging/established/mature) from repo_created_at, CHAOSS-informed (the maturity analysis) — a more direct answer than the age/release-cadence trend chart alone, which remains a secondary Understanding-tier view of the same underlying signal.","MIXED: maturity (direct classification) + Understanding trend chart (stats.py, project_stats history) [checks: maturity:project_maturity]",Code Analysis,,,,,,,,X,X,,X,X,Select;Assess, +Do we have the skills to support its use?,Analysis/Enrichment,Operational readiness and skill gaps.,Purely human-supplied — never resource-derivable.,N/A — human-supplied via Enrichment Context form - Egeria queries as well,Human-Supplied + Egeria Queries,,,,,,,,,,X,X,X,Select;Learn, +How well documented is it?,Analysis,Ease of adoption and maintenance.,"Covers root README, CHANGELOG and CONTRIBUTING presence with a quality label, plus whole-tree density — README count, a docs folder, .md/.txt count.","MIXED: documentation_coverage + repo_conventions (doc_breadth) perhaps also an Agent? documentation_coverage/repo_conventions give structured presence signals; an Agent reviewing the whole set (READMEs in every folder, a separate docs directory, tutorials, etc.) can judge documentation quality more holistically than presence-checks alone. [checks: repo_conventions:doc_breadth]",Code Analysis + Agent-Based Analysis,,,,,X,,,X,X,,X,X,Select;Learn;Share,documentation_coverage checks the root; repo_conventions' doc_breadth added the tree-wide density signal the root-only check missed. +How is it supported?,Analysis,Enterprise fallback and support options.,"Combines community responsiveness, documentation quality and any formal support documents.",MIXED: repository_health (community responsiveness) + documentation_coverage + sla_content (published support commitments),Git Statistics + Code Analysis,X,,,,X,,,,X,,X,X,Select;Maintain,"Mixed across tiers: responsiveness from Scouting, documentation from Assessment, support docs from Analysis/RAG." +"Is there a current, published, security analysis?",Analysis,Third-party audit and security posture.,"Not answered — a published third-party audit is a different artifact from these checks, and nothing detects one.","MIXED: cii_badge (the real OpenSSF Best Practices badge, read from bestpractices.dev) + foss_scorecard (OpenSSF-Scorecard-shaped checks computed locally, NOT the upstream tool's own run) + chaoss_metrics; security_scan + security_features + repo_conventions for hygiene signals",External Service Query + Code Analysis,,X,,,,,,,,X,X,X,Assess;Certify,"The GAP note named OpenSSF Scorecard and CHAOSS as candidate tooling; both landed 2026-08-26 as foss_scorecard and chaoss_metrics, and cii_badge reads the published badge. The question stays MIXED rather than answered." +Does it fit into our governance frameworks?,Analysis/Enrichment,Compliance and regulatory alignment.,"Org policy cross-reference (human-supplied), plus Curate's zone/catalog membership once published.","N/A — human-supplied via Enrichment Context form, plus Curate zone/catalog membership (egeria_publish), plus Egeria queries.",Human-Supplied + Egeria Queries,,X,,,,,,,,X,X,X,Certify;Share, +"What explicit license does the repository use, and are there non-standard or copyleft terms?",Assessment,"Identifies strict copyleft (GPL/AGPL), source-available shifts (BSL/SSPL), or dual-licensing risks before code integration.","GitHub API license field gives the SPDX id cheaply (Scouting); license_classification (Assessment) categorizes it into a risk tier (permissive/weak copyleft/strong copyleft/source-available/unknown) — candidate reference tools for deeper provenance: FOSSology (Linux Foundation compliance workspace), LicenseFinder, ClearlyDefined (crowdsourced licensing/provenance data), Mend, Black Duck. SPDX (ISO/IEC 5962) is the standard schema this conforms to.",license_classification [checks: license_classification:license_risk_tier],Code Analysis,X,,,X,,X,,,,X,,,Select;Certify, +Is intellectual property (IP) provenance managed via CLA or DCO?,Analysis,Protects the organization from copyright infringement claims from unauthorized third-party contributions.,"Reports which mechanism is in use — DCO sign-off in commit trailers, a CLA bot configuration, or a CONTRIBUTING.md statement — rather than a yes/no.",contribution_provenance,Code Analysis,X,X,X,X,,,,,,X,,,Select;Certify,contribution_provenance shipped 2026-09-01; the row was marked GAP until 2026-09-02. +"Does the software contain telemetry, phone-home mechanisms, or external metrics tracking?",Analysis,"Prevents unauthorized leakage of internal network metadata, system details, or usage patterns.","Matches known telemetry and analytics SDK imports and endpoints in source, and separately checks whether each match is disclosed in the repo's own docs. An undisclosed match is the finding, not the presence of telemetry.",telemetry_scan,Code Analysis,,,,,,,X,,,X,X,X,Assess;Certify,telemetry_scan shipped 2026-09-01; the row was marked GAP until 2026-09-02. +"For AI/ML assets, what licensing or usage constraints apply to models, weights, or datasets?",Analysis,"Open-source software licenses (e.g., Apache 2.0) often differ from model weights (e.g., OpenRAIL, Llama) or training data constraints.","Needs model-card/dataset-file detection — real gap, AI Governance tooling category.",GAP: model-card/dataset-file detection (AI Governance tooling category),Gap,,X,,,,X,X,,X,,,,Select;Certify, +"How does the repository handle secrets, credentials, and sensitive configurations?",Analysis,Prevents hardcoded secrets or unencrypted credential passing in containerized and automated deployment environments.,"Runs a vendored gitleaks ruleset over the HEAD snapshot of tracked files, naming the ruleset and commit. Never claims 'no secrets' — only 'no matches against this ruleset, in this snapshot' — and says nothing about git history, where a removed secret still lives.",secret_scan,Code Analysis,,,,,,,,,,X,X,X,Assess;Certify,secret_scan shipped 2026-09-01; the row was marked GAP until 2026-09-02. +Does the repository publish a clear process for reporting security vulnerabilities?,Analysis,Knowing how to responsibly disclose a finding — and whether maintainers take security seriously — matters before we rely on this.,"Reads SECURITY.md's content for an actual disclosure process, not just the file's presence — which is reported separately.",repo_conventions + security_scan [checks: repo_conventions:security_policy_content],Code Analysis,,X,,,,,,,,X,,,Certify,"The row once read 'repo_conventions - RAG read?', which the generator could not parse into a kind, so the question was unanswerable despite being correctly wired." +Does the repository have automated build tooling in place?,Analysis,Independent of CI wiring — a Makefile/build.gradle/pyproject build-system table means it can be built locally even if CI is absent or broken.,"Covers whether build tooling configuration is present and whether CI actually runs, tests and gates.",repo_conventions + ci_quality [checks: repo_conventions:automated_build],Code Analysis,,,,,,X,,,,,X,,Certify;Deploy,Same parser problem as the vulnerability-reporting row: a trailing '- RAG read?' left the kind unknown and the question unanswerable. +Is this repository already self-described for an enterprise catalog (e.g. Backstage catalog-info.yaml)?,Assessment,"A strong, cheap signal that this repo is already integrated into some enterprise inventory, independent of which catalog it names.",repo_conventions detects catalog-info.yaml and equivalent self-description files. Same parser problem — the row was wired correctly and phrased in a way the generator read as unknown.,repo_conventions [checks: repo_conventions:catalog_info],Code Analysis,,X,X,,,,,,,,X,,Share, +"Has this resource already been surveyed at any tier, and what did earlier signals reveal?",Discovery,Avoid re-deriving what's already known; ground the decision in prior signal instead of starting cold.,"Direct field — last_surveyed_at, is_published (egeria_asset_guid), and any locally-cached findings/metrics history already surfaced by the resource's own analysis cards.",N/A — direct field (last_surveyed_at/egeria_asset_guid; scoped analysis-history queries),Direct Field,,,X,,,,,,X,,X,,Explore;Maintain, +Which Survey Definition should I run — a quick coarse check or the full deep survey?,Discovery,"Running the wrong tier wastes time (full survey when a coarse check would do) or leaves real gaps (coarse-only when depth was actually needed) — the choice should be informed, not default.",Survey Definitions carry their own step lists/descriptions and survey_kind tag; the Discovery candidates list surfaces exactly this per-step breakdown before committing to run one.,"N/A — direct field (Survey Definition candidates list, step/survey_kind detail)",Direct Field,,,X,,,,,,X,,X,X,Explore, +"Based on what's already known, is this worth investigating further, or should it be deprioritized?",Discovery,"Discovery-tier signal (license risk, maturity, conventions) is exactly the point a go/no-go call should get made and recorded, not lost.","Disposition control (Track/Investigate/Recommend/Abandon/Ignore) plus history — the same repo_dispositions state Scouting already exposes, now reachable directly from Discovery too.",N/A — direct field (repo_dispositions) - there is some heuristic we might need to compute to recommend a disposition,Direct Field,,X,X,X,,,,,,,,,Explore;Select, +How much has changed since the last time this was surveyed — is it worth re-running now?,Automate,Re-surveying on a blind schedule wastes cycles; knowing whether anything material actually changed first is the real Discovery-tier judgment.,"notification_detector.py's latest-two-runs comparison, surfaced via an Automate subscription once a schedule exists for the analysis — real today, but only reachable once that subscription is set up.",N/A — Automate subscription (notification_detector's change comparison),Automate Change Detection,,,X,,,,,,X,,,,Maintain, +"Is there a Survey Definition authored for this resource's technology type at all, or is that a catalog gap?",Discovery,"A resource type with no Survey Definition authored yet is itself a real, actionable finding — a catalog gap someone should close — not a silent empty list.",Direct field — the candidates list itself; an empty result for a technology type with real registered resources is the signal.,"N/A — direct field (Survey Definition candidates list, empty-result case)",Direct Field,,X,,,,,,,,,,X,Explore;Share, +"What kind of thing is this repository — a library, an application, a tool, or samples?",Discovery,"The same signal means different things depending on what the repo is; a tutorial with no tests is fine, a library with none is not.","repo_classification exists and nothing asked for it. It also decides whether architecture recovery is worth running, so it gates later tiers.",repo_classification,Code Analysis,,,,,X,,,,X,,X,,Explore;Select, +"What components exist in this repository, and what kind is each?",Discovery,"I need to know what I would be taking on before committing to it, and whether its own documentation matches what the code actually does.","architecture_recovery, architecture_summary and architecture_doc_lens all exist; no question referenced any of them. The doc lens specifically compares documented architecture against recovered architecture.",architecture_recovery + architecture_summary + architecture_doc_lens,Code Analysis,,,,,,X,,,X,,X,,Explore;Learn;Select, +How do its components relate to each other?,Discovery,"I need to know what I would be taking on before committing to it, and whether its own documentation matches what the code actually does.","Split from the combined 'internal architecture' question 2026-09-08 (Dan: is a separate question worth splitting out; chat-facts rendering cannot flatten a component graph into bullet text at any real scale, so this half needs its own answer, not a share of the other one's). architecture_diagram is the Mermaid diagram + caption rendered from architecture_recovery/architecture_summary at survey time -- previously computed and persisted, never read back by anything.",architecture_diagram,Code Analysis,,,,,,X,,,X,,X,,Explore;Learn;Select, +What languages and file types make up this repository?,Scouting,Language mix is the cheapest signal of whether this is something we can maintain in-house.,language_file_classification is one of the oldest analyses here and no question referenced it.,language_file_classification,Code Analysis,,,,,X,,,,X,,X,,Explore;Select, +"What data files does it ship, and what shape are they?",Analysis,Bundled data can carry licensing and privacy obligations that the repo's own licence does not cover.,An inventory and shape profile of the data files shipped in the repository.,data_file_profiling,Code Analysis,,,,X,,,X,,X,,,,Explore;Assess,data_file_profiling existed before any question referenced it. +What APIs and code symbols does it expose to callers?,Analysis,"The published surface is what we would actually be coupling to, and what breaks on upgrade.",Covers the extracted code symbols and the interfaces implied by dependencies; a published contract is detected only where a specification file exists.,api_structure + interface_surface,Code Analysis,,,,,X,X,,,X,,X,,Select;Deploy;Learn,api_structure and interface_surface both existed before any question referenced them. +How does it score against OpenSSF Scorecard-style criteria?,Assessment,"A named, comparable rubric is what lets me weigh several candidates against each other rather than judging each on its own terms.","foss_scorecard computes Scorecard-shaped checks from data already collected — it is NOT the upstream OpenSSF tool's own run, and a check it cannot evaluate is reported as not-established rather than as a failure.",foss_scorecard,Code Analysis,,X,X,,,,,,,X,,,Assess;Certify;Select, +"Does it hold an OpenSSF Best Practices (CII) badge, and how current is the self-assessment behind it?",Assessment,A badge is a public claim by the project about itself; its age tells me how much the claim is still worth.,"cii_badge reads the real badge from bestpractices.dev rather than estimating one, and reports level together with the age of the self-assessment. No question referenced it.",cii_badge,External Service Query,,X,X,,,,,X,,X,,,Assess;Certify;Attest, +How concentrated is authorship — would the project survive losing its top contributors?,Assessment,A healthy commit rate produced by one person is a different risk from the same rate produced by twenty.,"chaoss_metrics reports the elephant factor and related CHAOSS metrics on their own terms, never averaged into a score. No question referenced it.",chaoss_metrics,Git Statistics,,X,X,,,,,X,,,,,Assess;Maintain, +How much code is there? How complex?,Assessment,Gives an estimate on cost to maintain.,"Both halves are answerable as of 2026-09-02. SIZE: language_file_classification carries a per-language line census — code, comment, docstring and blank — written at ingestion and at Coarse Profile refresh (pipeline.py::_record_line_census). Markdown, JSON, HTML, CSS, SQL and XML are counted as text and excluded from the code total by construction; project_stats.ingestion_lines_of_code, which counted every newline in every text file and reported 1,118,195 against ~156k real code lines on egeria-python, is retired and must not be cited. COMPLEXITY: api_structure carries the per-language max/mean it always computed, python and java only — go and javascript store 0 for every symbol because their extractors never compute it, so they are excluded and named rather than averaged in (0.32 vs 2.69 on milvus). A repo with no census reports no line counts rather than zero: not counted is not the same as no code.",language_file_classification + api_structure,Code Analysis,,,,,,x,,,,x,x,x,Assess;Maintain, +What are the public interfaces?,Discovery,How do we use this - does it look like it does what we want?,"interface_surface detects which interface styles a repo exposes (http_api, grpc, graphql, messaging, soap, cli) and whether a contract is actually published, versus merely implied by a dependency. Discovery altitude: 'is there a published contract' rather than 'what symbols exist', which is the Analysis-stage question 'What APIs and code symbols does it expose to callers?' (api_structure + interface_surface).",interface_surface,Code Analysis,,,,,,x,x,,,x,x,x,Assess;Deploy;Explore;Learn, diff --git a/packages/resource-explorer/resource_explorer/configdata/question_catalog.yaml b/packages/resource-explorer/resource_explorer/configdata/question_catalog.yaml index b29ed66df..b7bb969b7 100644 --- a/packages/resource-explorer/resource_explorer/configdata/question_catalog.yaml +++ b/packages/resource-explorer/resource_explorer/configdata/question_catalog.yaml @@ -55,6 +55,12 @@ # dependencies only. These are the caveats that turn a # finding into a claim, so they are carried to the UI # rather than left in the CSV. Added 2026-09-11. +# catalog_history - the CSV's "Catalog History" column, verbatim — what +# used to be wrong, which analysis landed when, what the +# generator could not parse. Past tense, for whoever +# maintains the catalog; split out of Rationale/Source on +# 2026-09-11 so the limit and the changelog stop sharing +# one sentence and one colour on screen. repo_questions: - question: Is this repository actively maintained? @@ -77,6 +83,7 @@ repo_questions: answering_mechanism: Git Statistics + Code Analysis rationale: Recency/frequency signal — last-pushed date, commit cadence — answerable from GitHub API metadata alone (RE's HealthSurveyor), no zipball needed. + catalog_history: '' - question: What does this repository do? stage: Scouting perspectives: @@ -94,6 +101,7 @@ repo_questions: note: N/A — direct field (Project.description) This might also be a summary of the top level README. answering_mechanism: Direct Field rationale: Purpose/description — from the repo's own GitHub description field, no deeper analysis required. + catalog_history: '' - question: Who maintains this repository? stage: Scouting perspectives: @@ -113,6 +121,7 @@ repo_questions: answering_mechanism: Git Statistics rationale: repository_health alone answers 'is it maintained', not 'by whom' — the two questions returned the same text until chaoss_metrics, built on project_commits, gave this one its own source. + catalog_history: '' - question: How widely adopted and active is the community around this repository? stage: Scouting perspectives: @@ -134,6 +143,7 @@ repo_questions: rationale: Stars are attention, not community. community_support reports the dimensions separately and names the weakest rather than averaging, so 'popular but unsupported' stays visible instead of averaging out. + catalog_history: '' - question: Has this repository already been catalogued in Egeria and when? stage: Discovery perspectives: @@ -152,6 +162,7 @@ repo_questions: rationale: '"Known inventory" check — is there already a SourceControlLibrary asset for this repo, independent of whether it''s registered in Resource Explorer''s own local registry. Ties to the Scouting "shop for data" search work.' + catalog_history: '' - question: Are there outstanding CVEs? stage: Analysis perspectives: @@ -169,9 +180,9 @@ repo_questions: checks: [] note: cve_scan answering_mechanism: External Service Query - rationale: Closed 2026-08-26 by cve_scan, which queries OSV.dev for advisories against the dependencies - manifest_parse recorded. Reports coverage as prominently as the count — DECLARED dependencies only, - so a zero is 'none found in what we can see', not 'none exist'. + rationale: Covers advisories against DECLARED dependencies only, queried from OSV.dev, with coverage + reported as prominently as the count — so a zero is 'none found in what we can see', not 'none exist'. + catalog_history: Closed 2026-08-26 by cve_scan, reading the dependencies manifest_parse recorded. - question: Is there any existing use within our organization? stage: Discovery perspectives: @@ -194,6 +205,7 @@ repo_questions: answering_mechanism: Local Registry Query + Egeria Queries rationale: Answerable locally from RE's own registry (cross-reference against already-registered repos/groups) — no external API needed. + catalog_history: '' - question: Any known feedback? stage: Discovery perspectives: @@ -214,6 +226,7 @@ repo_questions: note: N/A — direct field (resource_feedback table) - result from Egeriq queries/report answering_mechanism: Local Registry Query + Egeria Queries rationale: RE's Curate intent already holds resource_feedback for cataloged resources. + catalog_history: '' - question: Does it replace or extend something we already have? stage: Discovery perspectives: @@ -229,6 +242,7 @@ repo_questions: note: N/A — RE registry query (already-registered repos/groups) - might need RAG support for this. answering_mechanism: Local Registry Query + RAG Queries rationale: Answerable locally from RE's own registry/groups, same as "existing use within our organization." + catalog_history: '' - question: What are similar repos/projects? How does this differ? stage: Analysis perspectives: @@ -244,6 +258,7 @@ repo_questions: note: 'GAP: similarity search over pgvector embeddings (not built) - Agent over RAG' answering_mechanism: Agent-Based Analysis rationale: Needs similarity search over content (RAG/embeddings) — real gap, not built. + catalog_history: '' - question: Are there any restrictions for use? stage: Analysis perspectives: @@ -264,6 +279,7 @@ repo_questions: answering_mechanism: Direct Field (standard license) + Agent-Based Analysis (non-standard license interpretation) rationale: License field is cheaply available from the GitHub API — deeper copyleft-risk categorization is Assessment-tier (see the license-specifics row below). + catalog_history: '' - question: What dependencies does this require? stage: Analysis perspectives: @@ -282,7 +298,8 @@ repo_questions: checks: [] note: dependency_analysis answering_mechanism: Code Analysis - rationale: DependencySurveyor exists (Analysis-tier) — dependency list per ecosystem. + rationale: The declared dependency list, per ecosystem, from the manifests present. + catalog_history: Answered by DependencySurveyor (Analysis tier). - question: Do we already support these dependencies? stage: Analysis perspectives: @@ -301,6 +318,7 @@ repo_questions: by human input — likely needs a per-dependency answer, not one blanket answer for the whole question. answering_mechanism: Egeria Queries + Human-Supplied rationale: Needs the org's own tech inventory — human-supplied cross-reference, not resource-derivable. + catalog_history: '' - question: What deployment styles does this support? stage: Analysis perspectives: @@ -319,9 +337,10 @@ repo_questions: note: 'MIXED: repo_conventions (deployment_docker presence signal); GAP: RAG content read for deployment instructions [checks: repo_conventions:deployment_docker]' answering_mechanism: Code Analysis + RAG Queries - rationale: Presence of Dockerfile/compose/Helm chart is now a cheap Discovery-tier signal (repo_conventions' - deployment_docker check); actual deployment *instructions* still need content read (Dockerfile, deployment - docs) — RAG-tier, real gap. + rationale: Covers whether a Dockerfile, compose file or Helm chart is present. What the deployment instructions + actually say is not read. + catalog_history: Presence became a cheap Discovery-tier signal through repo_conventions' deployment_docker + check; a content read of Dockerfiles and deployment docs remains a RAG-tier gap. - question: Do we know what the cost to run it is? stage: Analysis perspectives: @@ -337,6 +356,7 @@ repo_questions: note: N/A — human-supplied via Enrichment Context form = may also be Agent over RAG content. answering_mechanism: Human-Supplied rationale: Human-supplied/derived — not resource-derivable. + catalog_history: '' - question: Is there a validation / deployment test for it? stage: Analysis perspectives: @@ -358,9 +378,10 @@ repo_questions: signals; an Agent examining the repo''s actual file/directory structure (test dirs, validation scripts) can find hints beyond those fixed checks. [checks: ci_quality:ci_runs_tests, repo_conventions:deployment_docker]' answering_mechanism: Code Analysis + Agent-Based Analysis - rationale: ci_quality checks whether CI workflows actually run tests (not just exist); repo_conventions' - deployment_docker adds deployment-evidence signal — together a much better fit than CI-config-presence - alone; a real deployment *validation* test is still a gap. + rationale: Covers whether CI runs tests and whether deployment evidence exists; a real deployment validation + test is not detected. + catalog_history: ci_quality replaced a CI-config-presence check with one that reads whether workflows + run tests; repo_conventions' deployment_docker added the deployment-evidence signal. - question: What is the upgrade process? stage: Analysis perspectives: @@ -378,6 +399,7 @@ repo_questions: note: 'GAP: RAG content read (CHANGELOG/release notes) — no structured surveyor' answering_mechanism: RAG Queries rationale: Needs content read (CHANGELOG, release notes) — RAG-tier, real gap. + catalog_history: '' - question: Does it fit into our monitoring infrastructure? stage: Analysis/Enrichment perspectives: @@ -396,6 +418,7 @@ repo_questions: first - augmented with enrichment. answering_mechanism: Egeria Queries + Human-Supplied rationale: Org-internal cross-reference — human-supplied. + catalog_history: '' - question: Does it fit into our security infrastructure? stage: Analysis/Enrichment perspectives: @@ -417,7 +440,9 @@ repo_questions: note: N/A — human-supplied via Enrichment Context form, informed by security_scan findings and Egeria queries. answering_mechanism: Code Analysis + Egeria Queries + Human-Supplied - rationale: Org-internal cross-reference, informed by Assessment's security findings. + rationale: 'An organisation-internal cross-reference: the security findings inform it, a person answers + it.' + catalog_history: '' - question: Is there any reliability or availability aspects? stage: Analysis perspectives: @@ -436,6 +461,7 @@ repo_questions: answering_mechanism: Trend Chart rationale: Trend/uptime signal, if available — Understanding-tier chart, or Analysis if content-derived (SLA docs). + catalog_history: '' - question: What kinds of integrations does it support? stage: Analysis perspectives: @@ -456,8 +482,9 @@ repo_questions: — from the file inventory and declared dependencies); GAP remains for the prose read of integration guides' answering_mechanism: Code Analysis + RAG Queries - rationale: interface_surface answers the structural half — which interfaces exist and whether they are - documented. Which named third-party systems it integrates with still needs a content read and has + rationale: Covers which interfaces exist and whether they are documented. Which named third-party systems + it integrates with is not read. + catalog_history: interface_surface answers the structural half; the content read for named systems has no surveyor. - question: Does this fit within or need to extend our existing infrastructure estate? stage: Analysis/Enrichment @@ -476,6 +503,7 @@ repo_questions: note: N/A — human-supplied via Enrichment Context form with as fallback from Egeria queries. answering_mechanism: Human-Supplied + Egeria Queries rationale: Org-internal cross-reference — human-supplied. + catalog_history: '' - question: How mature is it? stage: Assessment perspectives: @@ -498,6 +526,7 @@ repo_questions: rationale: Direct lifecycle-stage classification (nascent/emerging/established/mature) from repo_created_at, CHAOSS-informed (the maturity analysis) — a more direct answer than the age/release-cadence trend chart alone, which remains a secondary Understanding-tier view of the same underlying signal. + catalog_history: '' - question: Do we have the skills to support its use? stage: Analysis/Enrichment perspectives: @@ -514,6 +543,7 @@ repo_questions: note: N/A — human-supplied via Enrichment Context form - Egeria queries as well answering_mechanism: Human-Supplied + Egeria Queries rationale: Purely human-supplied — never resource-derivable. + catalog_history: '' - question: How well documented is it? stage: Analysis perspectives: @@ -538,9 +568,10 @@ repo_questions: docs directory, tutorials, etc.) can judge documentation quality more holistically than presence-checks alone. [checks: repo_conventions:doc_breadth]' answering_mechanism: Code Analysis + Agent-Based Analysis - rationale: documentation_coverage checks root README/CHANGELOG/CONTRIBUTING presence + quality label; - repo_conventions' doc_breadth adds a whole-tree density signal (README count, docs/ folder, .md/.txt - count) that the root-only check misses. + rationale: Covers root README, CHANGELOG and CONTRIBUTING presence with a quality label, plus whole-tree + density — README count, a docs folder, .md/.txt count. + catalog_history: documentation_coverage checks the root; repo_conventions' doc_breadth added the tree-wide + density signal the root-only check missed. - question: How is it supported? stage: Analysis perspectives: @@ -562,8 +593,9 @@ repo_questions: note: 'MIXED: repository_health (community responsiveness) + documentation_coverage + sla_content (published support commitments)' answering_mechanism: Git Statistics + Code Analysis - rationale: 'Mixed: community responsiveness (Scouting-cheap) + documentation quality (Assessment) + - any formal support docs (Analysis/RAG).' + rationale: Combines community responsiveness, documentation quality and any formal support documents. + catalog_history: 'Mixed across tiers: responsiveness from Scouting, documentation from Assessment, support + docs from Analysis/RAG.' - question: Is there a current, published, security analysis? stage: Analysis perspectives: @@ -588,10 +620,11 @@ repo_questions: (OpenSSF-Scorecard-shaped checks computed locally, NOT the upstream tool''s own run) + chaoss_metrics; security_scan + security_features + repo_conventions for hygiene signals' answering_mechanism: External Service Query + Code Analysis - rationale: 'The GAP note here named OpenSSF Scorecard and CHAOSS as candidate tooling; both landed 2026-08-26 - as foss_scorecard and chaoss_metrics, and cii_badge reads the real published badge. Still MIXED rather - than answered: a published third-party audit is a different artifact from any of these, and nothing - detects one.' + rationale: Not answered — a published third-party audit is a different artifact from these checks, and + nothing detects one. + catalog_history: The GAP note named OpenSSF Scorecard and CHAOSS as candidate tooling; both landed 2026-08-26 + as foss_scorecard and chaoss_metrics, and cii_badge reads the published badge. The question stays + MIXED rather than answered. - question: Does it fit into our governance frameworks? stage: Analysis/Enrichment perspectives: @@ -610,6 +643,7 @@ repo_questions: plus Egeria queries. answering_mechanism: Human-Supplied + Egeria Queries rationale: Org policy cross-reference (human-supplied), plus Curate's zone/catalog membership once published. + catalog_history: '' - question: What explicit license does the repository use, and are there non-standard or copyleft terms? stage: Assessment perspectives: @@ -633,6 +667,7 @@ repo_questions: — candidate reference tools for deeper provenance: FOSSology (Linux Foundation compliance workspace), LicenseFinder, ClearlyDefined (crowdsourced licensing/provenance data), Mend, Black Duck. SPDX (ISO/IEC 5962) is the standard schema this conforms to.' + catalog_history: '' - question: Is intellectual property (IP) provenance managed via CLA or DCO? stage: Analysis perspectives: @@ -651,9 +686,9 @@ repo_questions: checks: [] note: contribution_provenance answering_mechanism: Code Analysis - rationale: contribution_provenance detects CLA/DCO signals — DCO sign-off in commit trailers, CLA bot - configuration, and CONTRIBUTING.md statements — and reports which mechanism is in use rather than - a yes/no. Was marked GAP until 2026-09-02; the analysis shipped 2026-09-01 and the row was never updated. + rationale: Reports which mechanism is in use — DCO sign-off in commit trailers, a CLA bot configuration, + or a CONTRIBUTING.md statement — rather than a yes/no. + catalog_history: contribution_provenance shipped 2026-09-01; the row was marked GAP until 2026-09-02. - question: Does the software contain telemetry, phone-home mechanisms, or external metrics tracking? stage: Analysis perspectives: @@ -671,10 +706,10 @@ repo_questions: checks: [] note: telemetry_scan answering_mechanism: Code Analysis - rationale: telemetry_scan matches known telemetry/analytics SDK imports and endpoints against source, - and separately checks whether any match is disclosed in the repo's own docs — an undisclosed match - is the finding, not the presence of telemetry. Was marked GAP until 2026-09-02; the analysis shipped - 2026-09-01. + rationale: Matches known telemetry and analytics SDK imports and endpoints in source, and separately + checks whether each match is disclosed in the repo's own docs. An undisclosed match is the finding, + not the presence of telemetry. + catalog_history: telemetry_scan shipped 2026-09-01; the row was marked GAP until 2026-09-02. - question: For AI/ML assets, what licensing or usage constraints apply to models, weights, or datasets? stage: Analysis perspectives: @@ -692,6 +727,7 @@ repo_questions: note: 'GAP: model-card/dataset-file detection (AI Governance tooling category)' answering_mechanism: Gap rationale: Needs model-card/dataset-file detection — real gap, AI Governance tooling category. + catalog_history: '' - question: How does the repository handle secrets, credentials, and sensitive configurations? stage: Analysis perspectives: @@ -708,10 +744,10 @@ repo_questions: checks: [] note: secret_scan answering_mechanism: Code Analysis - rationale: secret_scan runs a vendored gitleaks ruleset over the HEAD snapshot of tracked files, reporting - which ruleset and commit produced the result. Never claims 'no secrets' — only 'no matches against - this ruleset, in this snapshot' — and says nothing about git history, where a removed secret still - lives. Was marked GAP until 2026-09-02; the analysis shipped 2026-09-01. + rationale: Runs a vendored gitleaks ruleset over the HEAD snapshot of tracked files, naming the ruleset + and commit. Never claims 'no secrets' — only 'no matches against this ruleset, in this snapshot' — + and says nothing about git history, where a removed secret still lives. + catalog_history: secret_scan shipped 2026-09-01; the row was marked GAP until 2026-09-02. - question: Does the repository publish a clear process for reporting security vulnerabilities? stage: Analysis perspectives: @@ -728,10 +764,10 @@ repo_questions: - repo_conventions:security_policy_content note: 'repo_conventions + security_scan [checks: repo_conventions:security_policy_content]' answering_mechanism: Code Analysis - rationale: repo_conventions reads SECURITY.md's CONTENT (is there an actual disclosure process, not - just a file); security_scan's hygiene check reports the file's presence. The row read 'repo_conventions - - RAG read?', which the generator could not parse into a kind, so the question was unanswerable despite - being correctly wired. + rationale: Reads SECURITY.md's content for an actual disclosure process, not just the file's presence + — which is reported separately. + catalog_history: The row once read 'repo_conventions - RAG read?', which the generator could not parse + into a kind, so the question was unanswerable despite being correctly wired. - question: Does the repository have automated build tooling in place? stage: Analysis perspectives: @@ -749,9 +785,10 @@ repo_questions: - repo_conventions:automated_build note: 'repo_conventions + ci_quality [checks: repo_conventions:automated_build]' answering_mechanism: Code Analysis - rationale: repo_conventions detects build tooling configuration; ci_quality reports whether CI actually - runs, tests and gates. Same parser problem as above — the trailing '- RAG read?' left the row as kind=unknown - and therefore unanswerable. + rationale: Covers whether build tooling configuration is present and whether CI actually runs, tests + and gates. + catalog_history: 'Same parser problem as the vulnerability-reporting row: a trailing ''- RAG read?'' + left the kind unknown and the question unanswerable.' - question: Is this repository already self-described for an enterprise catalog (e.g. Backstage catalog-info.yaml)? stage: Assessment perspectives: @@ -770,6 +807,7 @@ repo_questions: answering_mechanism: Code Analysis rationale: repo_conventions detects catalog-info.yaml and equivalent self-description files. Same parser problem — the row was wired correctly and phrased in a way the generator read as unknown. + catalog_history: '' - question: Has this resource already been surveyed at any tier, and what did earlier signals reveal? stage: Discovery perspectives: @@ -787,6 +825,7 @@ repo_questions: answering_mechanism: Direct Field rationale: Direct field — last_surveyed_at, is_published (egeria_asset_guid), and any locally-cached findings/metrics history already surfaced by the resource's own analysis cards. + catalog_history: '' - question: Which Survey Definition should I run — a quick coarse check or the full deep survey? stage: Discovery perspectives: @@ -804,6 +843,7 @@ repo_questions: answering_mechanism: Direct Field rationale: Survey Definitions carry their own step lists/descriptions and survey_kind tag; the Discovery candidates list surfaces exactly this per-step breakdown before committing to run one. + catalog_history: '' - question: Based on what's already known, is this worth investigating further, or should it be deprioritized? stage: Discovery perspectives: @@ -822,6 +862,7 @@ repo_questions: answering_mechanism: Direct Field rationale: Disposition control (Track/Investigate/Recommend/Abandon/Ignore) plus history — the same repo_dispositions state Scouting already exposes, now reachable directly from Discovery too. + catalog_history: '' - question: How much has changed since the last time this was surveyed — is it worth re-running now? stage: Automate perspectives: @@ -838,6 +879,7 @@ repo_questions: rationale: notification_detector.py's latest-two-runs comparison, surfaced via an Automate subscription once a schedule exists for the analysis — real today, but only reachable once that subscription is set up. + catalog_history: '' - question: Is there a Survey Definition authored for this resource's technology type at all, or is that a catalog gap? stage: Discovery @@ -855,6 +897,7 @@ repo_questions: answering_mechanism: Direct Field rationale: Direct field — the candidates list itself; an empty result for a technology type with real registered resources is the signal. + catalog_history: '' - question: What kind of thing is this repository — a library, an application, a tool, or samples? stage: Discovery perspectives: @@ -873,6 +916,7 @@ repo_questions: answering_mechanism: Code Analysis rationale: repo_classification exists and nothing asked for it. It also decides whether architecture recovery is worth running, so it gates later tiers. + catalog_history: '' - question: What components exist in this repository, and what kind is each? stage: Discovery perspectives: @@ -895,6 +939,7 @@ repo_questions: rationale: architecture_recovery, architecture_summary and architecture_doc_lens all exist; no question referenced any of them. The doc lens specifically compares documented architecture against recovered architecture. + catalog_history: '' - question: How do its components relate to each other? stage: Discovery perspectives: @@ -917,6 +962,7 @@ repo_questions: at any real scale, so this half needs its own answer, not a share of the other one''s). architecture_diagram is the Mermaid diagram + caption rendered from architecture_recovery/architecture_summary at survey time -- previously computed and persisted, never read back by anything.' + catalog_history: '' - question: What languages and file types make up this repository? stage: Scouting perspectives: @@ -935,6 +981,7 @@ repo_questions: answering_mechanism: Code Analysis rationale: language_file_classification is one of the oldest analyses here and no question referenced it. + catalog_history: '' - question: What data files does it ship, and what shape are they? stage: Analysis perspectives: @@ -951,7 +998,8 @@ repo_questions: checks: [] note: data_file_profiling answering_mechanism: Code Analysis - rationale: data_file_profiling exists and no question referenced it. + rationale: An inventory and shape profile of the data files shipped in the repository. + catalog_history: data_file_profiling existed before any question referenced it. - question: What APIs and code symbols does it expose to callers? stage: Analysis perspectives: @@ -971,7 +1019,9 @@ repo_questions: checks: [] note: api_structure + interface_surface answering_mechanism: Code Analysis - rationale: api_structure and interface_surface both exist; neither was referenced by a question. + rationale: Covers the extracted code symbols and the interfaces implied by dependencies; a published + contract is detected only where a specification file exists. + catalog_history: api_structure and interface_surface both existed before any question referenced them. - question: How does it score against OpenSSF Scorecard-style criteria? stage: Assessment perspectives: @@ -992,6 +1042,7 @@ repo_questions: rationale: foss_scorecard computes Scorecard-shaped checks from data already collected — it is NOT the upstream OpenSSF tool's own run, and a check it cannot evaluate is reported as not-established rather than as a failure. + catalog_history: '' - question: Does it hold an OpenSSF Best Practices (CII) badge, and how current is the self-assessment behind it? stage: Assessment @@ -1013,6 +1064,7 @@ repo_questions: answering_mechanism: External Service Query rationale: cii_badge reads the real badge from bestpractices.dev rather than estimating one, and reports level together with the age of the self-assessment. No question referenced it. + catalog_history: '' - question: How concentrated is authorship — would the project survive losing its top contributors? stage: Assessment perspectives: @@ -1031,6 +1083,7 @@ repo_questions: answering_mechanism: Git Statistics rationale: chaoss_metrics reports the elephant factor and related CHAOSS metrics on their own terms, never averaged into a score. No question referenced it. + catalog_history: '' - question: How much code is there? How complex? stage: Assessment perspectives: @@ -1058,6 +1111,7 @@ repo_questions: computed, python and java only — go and javascript store 0 for every symbol because their extractors never compute it, so they are excluded and named rather than averaged in (0.32 vs 2.69 on milvus). A repo with no census reports no line counts rather than zero: not counted is not the same as no code.' + catalog_history: '' - question: What are the public interfaces? stage: Discovery perspectives: @@ -1083,3 +1137,4 @@ repo_questions: Discovery altitude: ''is there a published contract'' rather than ''what symbols exist'', which is the Analysis-stage question ''What APIs and code symbols does it expose to callers?'' (api_structure + interface_surface).' + catalog_history: '' diff --git a/packages/resource-explorer/resource_explorer/surveyors/question_catalog_reader.py b/packages/resource-explorer/resource_explorer/surveyors/question_catalog_reader.py index a3c634869..d6402839b 100644 --- a/packages/resource-explorer/resource_explorer/surveyors/question_catalog_reader.py +++ b/packages/resource-explorer/resource_explorer/surveyors/question_catalog_reader.py @@ -72,6 +72,11 @@ class QuestionCatalogEntry: # Rationale/Source column. Carried to the UI because a caveat that lives # only in the catalog is a caveat nobody reading the answer ever sees. rationale: str = "" + # What used to be wrong and when it changed — the catalog's changelog for + # this row, past tense, for its maintainer. Kept apart from `rationale` + # so a reader deciding how far to trust an answer is not handed the + # build's history in the same breath. + catalog_history: str = "" def to_dict(self) -> dict: return { @@ -82,6 +87,7 @@ def to_dict(self) -> dict: "answering": self.answering.to_dict(), "answering_mechanism": self.answering_mechanism, "rationale": self.rationale, + "catalog_history": self.catalog_history, } @@ -100,6 +106,7 @@ def _entry_from_yaml(raw: dict) -> QuestionCatalogEntry: ), answering_mechanism=raw.get("answering_mechanism", ""), rationale=raw.get("rationale", ""), + catalog_history=raw.get("catalog_history", ""), ) diff --git a/packages/resource-explorer/resource_explorer/web/routes/projects.py b/packages/resource-explorer/resource_explorer/web/routes/projects.py index d99e81761..edeb11747 100644 --- a/packages/resource-explorer/resource_explorer/web/routes/projects.py +++ b/packages/resource-explorer/resource_explorer/web/routes/projects.py @@ -467,6 +467,8 @@ class QuestionChecklistEntry(BaseModel): # mean. secret_scan never claims "no secrets", only no matches against # this ruleset in this snapshot. rationale: str = "" + # The catalog's own changelog for this row — past tense, maintainer-facing. + catalog_history: str = "" # None = not applicable (direct/registry/human/chart/gap kinds — no RE # analysis backs these, so there's nothing to check); True/False only # for analysis/partial/mixed kinds, computed best-effort per resource. @@ -534,6 +536,7 @@ async def get_scouting_questions( note=answering["note"], answering_mechanism=e.get("answering_mechanism", ""), rationale=e.get("rationale", ""), + catalog_history=e.get("catalog_history", ""), has_data=has_data, purposes=e.get("purposes", []), derivation=e.get("derivation", {}), diff --git a/packages/resource-explorer/resource_explorer/web/static/next/app.js b/packages/resource-explorer/resource_explorer/web/static/next/app.js index 130516dd4..8cf9b39d3 100644 --- a/packages/resource-explorer/resource_explorer/web/static/next/app.js +++ b/packages/resource-explorer/resource_explorer/web/static/next/app.js @@ -252,6 +252,7 @@ const GLYPH = { answered: '✓', automatic: '✓', unrun: '○', + partial: '◐', human: '⚠', 'no-surveyor': '○', unclassified: '·', @@ -272,6 +273,7 @@ const GLYPH = { */ const STATE_TONE = { answered: { paper: 'text-state-ok', chrome: 'text-state-ok-on-dark' }, + partial: { paper: 'text-accent-ink', chrome: 'text-accent-on-dark' }, automatic: { paper: 'text-state-ok', chrome: 'text-state-ok-on-dark' }, unrun: { paper: 'text-state-warn', chrome: 'text-state-warn-on-dark' }, human: { paper: 'text-accent-ink', chrome: 'text-accent-on-dark' }, @@ -402,7 +404,12 @@ function readEnvelope(entry, env) { } // NOTE: `lines.answer` is HTML, already escaped by each branch above. // Do not run it through esc() or answerHtml() again downstream. - lines.answer = sentences.join(' '); + // Joined on ' · ', not ' '. Six analyses' sentences run together read as + // one broken sentence — "all 3 checks pass one contributor writes most of + // the code" — and a reader cannot tell where one claim ends. The separator + // makes the boundaries visible without deciding how the claims combine, + // which is the judgement still owed (Dashboard Round Three). + lines.answer = sentences.join(' · '); // The caveat — the most important content on the screen. These sentences // already exist in the survey output; they used to sit three panes away in @@ -3012,16 +3019,32 @@ async function historyHtml(slug, analysisId, metric = '') { be a trend; there isn't one yet.

`; } series.sort((a, b) => String(a.surveyed_at).localeCompare(String(b.surveyed_at))); + // A history is interesting exactly where it steps. Thirty rows of which + // twenty-eight are identical HIDE the two that matter — the dependency + // count went 57 to 68 over five weeks and a reader had to read thirty + // figures to notice. So the transitions lead: each value the series ever + // took, dated where it first appeared, and the count of runs and changes. + // The full table stays one click on, for the reader who wants every run. + const val = (r) => r.metric_value ?? r.value; + const steps = []; + for (const r of series) { + if (!steps.length || steps[steps.length - 1].v !== val(r)) steps.push({ v: val(r), at: r.surveyed_at, m: r.metric }); + } + const changes = steps.length - 1; + const transitions = steps.map((st) => `${esc(fmtScalar(st.v, st.m || metric))} + ${esc(String(st.at).slice(5, 10))}`).join(' → '); return ` -
History · oldest first · - ${series.length} recorded
+
${transitions} + · ${series.length} runs, ${changes} change${changes === 1 ? '' : 's'}
+
Every run · oldest first · + ${series.length} recorded ${series.map((r, i) => ``).join('')} -
${esc(String(r.surveyed_at).slice(0, 10))} ${ esc(fmtScalar(r.metric_value ?? r.value, r.metric || metric))}
`; +
`; } /** The inline delta beside a value: what it was, and when. @@ -3419,8 +3442,7 @@ function findingRowHtml(f, analysisId, when) { f.check_name ? `${esc(f.check_name.replace(/_/g, ' '))}${f.label ? ` — ${esc(humanLabel(f.label))}` : ''}` : esc(humanLabel(f.label) || analysisId)}. - ${f.summary ? ` ${tnum(esc(f.summary))}` : ''} - · + ${f.summary ? ` ${tnum(esc(f.summary))}` : ''} › `; } @@ -3510,7 +3532,13 @@ function analysisUnderQuestionHtml(fact, id, checks) { let findings = Array.isArray(value.findings) ? value.findings : []; if (checks) findings = findings.filter((f) => checks.has(String(f.check_name || ''))); findings = sortUnresolvedFirst(findings); - const { numbers, flags, unset } = checks ? { numbers: [], flags: [], unset: [] } : splitScalars(value); + const { numbers: allNumbers, flags, unset } = checks ? { numbers: [], flags: [], unset: [] } : splitScalars(value); + // An analysis whose state is "nothing found" has said so in its sentence. + // Four zeros under "Nothing ingested from site" restate it as a table, and + // a zero that means "nothing happened" reads as a measurement of nothing. + // The zeros are dropped only in that state; a zero from a measured run is + // a value and stays. + const numbers = fact.state === 'nothing_found' ? allNumbers.filter((c) => c.value !== 0) : allNumbers; const head = `
`; } @@ -3546,17 +3549,22 @@ function analysisUnderQuestionHtml(fact, id, checks) { ${tnum(esc(fact.headline || fact.note || fact.state || ''))}${ flags.length ? ` · ${flags.map((f) => `${esc(f.key.replace(/_/g, ' '))}: ${f.value ? 'yes' : 'no'}`).join(' · ')}` : ''} ${esc(id)}${when ? ` · ${esc(ago(when))}` : ''} › - `; + +
+ +
`; if (id === 'sub_resource_survey') return head + subResourceSummaryHtml(fact); const scopedAndFound = checks && findings.length; return (scopedAndFound ? '' : head) + (findings.length ? findingsHtml(findings, id, when) : '') + (unset.length ? `
not recorded: ${esc(unset.map((k) => k.replace(/_/g, ' ')).join(', '))}
` : '') + (numbers.length ? `${numbers.map((c) => ` - - + `).join('')}
${esc(c.key.replace(/_/g, ' '))}${esc(fmtScalar(c.value, c.key))}${esc(fmtScalar(c.value, c.key))} + members ›
` : ''); } @@ -3588,11 +3596,11 @@ function disputeHtml(key, rec, when) {
Disagreement · ${esc(key.replace(/_/g, ' '))}
${rec.map((x) => ``).join('')}
-
${rec.length} analyses report this name with different values; they may not be measuring the same thing. Open either to see what each counted.
+
${rec.length} analyses report this name with different values; they may not be measuring the same thing. Open either to see what each counted — the members list in the rail.
`; } @@ -3610,6 +3618,113 @@ function sectionState(q, answerEnv, facts, ids) { return 'answered'; } +/* ── The third door: the things a count counted ────────────────────────── + * + * There are three "show me more" requests on this pane and only one had a + * path. About the MEASUREMENT — where did 68 come from, has it changed, + * re-run it — is the popup. A question nobody asked — is this safe for + * customer data? — is chat. The one in the middle, and the most common: + * WHICH 68? Which 18 advisories, which 90 files, which three sub-resources + * were not worthy? Nothing opened that. The popup looked like the door and + * opened the number's history instead, so a reader learned that 18 had been + * 18 for a fortnight. + * + * Members render in the right rail, which on Analysis held an empty ask box + * and eighteen hundred pixels of nothing. Click a count, the rail lists the + * members; the centre column keeps its place. No modal, no navigation. + * + * Members nest — file → symbol today; endpoint → operation → schema when + * those are measured — and PURPOSE decides how much of the tree you see: + * intent to use wants the public surface, intent to maintain wants that + * plus the internal structure. One tree, two default expansions, because + * maintain is a superset. Only symbols carry a public/internal marker, and + * that marker is inferred from naming — the rail says so. + * + * This is also where two disagreeing counts get settled: "open either to + * see what each counted" was already the instruction on the disagreement + * block, and until now it could not be followed. + */ +function memberScope() { + return currentPurposes().includes('Maintain') ? 'all' : 'public'; +} + +async function openMembers({ slug, analysisId, metric = '', title = '' }) { + const out = $('rail-evidence'); + if (!out) return; + const scope = state.memberScope || memberScope(); + out.innerHTML = `
Reading the members of ${esc(title || analysisId)}…
`; + let data; + try { + data = await getMembers(slug, analysisId, { metric, scope }); + } catch (err) { + out.innerHTML = `
The members could not be read: ${esc(err.message)}
`; + return; + } + const groups = data.groups || []; + const shown = groups.reduce((n, g) => n + g.members.length, 0); + out.innerHTML = ` +
+ Members + ${data.total} · ${esc(data.title)} + +
+
+ ${esc(data.analysis_id)} + · + · scope + + + ${data.scope_honoured ? '' : `· scope not applicable to this set`} +
+ ${data.note ? `
${esc(data.note)}
` : ''} +
+ ${groups.map((g, gi) => `
+ ${g.count} · ${esc(g.name)} +
    + ${g.members.map((m) => `
  • + ${m.children_key + ? ` + ${m.count ?? ''}` + : `${esc(m.name)}`} + ${m.detail ? `${esc(m.detail)}` : ''} +
  • `).join('')} + ${g.truncated ? `
  • and more — the first ${g.members.length} are shown
  • ` : ''} +
+
`).join('')} +
+ ${shown < data.total && !groups.some((g) => g.truncated) + ? `
${shown} of ${data.total} listed; the rest are nested under what is shown
` : ''} +
read from ${esc(data.source)}
`; + + out.querySelector('[data-act="close-members"]')?.addEventListener('click', () => { out.innerHTML = ''; }); + out.querySelector('[data-act="member-history"]')?.addEventListener('click', () => openMeasurementDetail({ + slug, analysisId, title: title || analysisId, metric, + })); + out.querySelectorAll('[data-scope]').forEach((b) => b.addEventListener('click', () => { + state.memberScope = b.dataset.scope; + openMembers({ slug, analysisId, metric, title }); + })); + // One level down, on demand: a file opens its symbols in place. + out.querySelectorAll('[data-children]').forEach((b) => b.addEventListener('click', async () => { + const li = b.closest('li'); + if (li.querySelector('ul')) { li.querySelector('ul').remove(); return; } + b.textContent = `${b.textContent} …`; + let rows; + try { rows = (await getMemberChildren(slug, analysisId, b.dataset.children, { scope })).members || []; } + catch (err) { rows = [{ name: `could not read: ${err.message}`, detail: '' }]; } + b.textContent = b.textContent.replace(/ …$/, ''); + const ul = document.createElement('ul'); + ul.className = 'm-0 mt-[2px] w-full list-none p-0 pl-s3'; + ul.innerHTML = rows.map((m) => `
  • + ${esc(m.name)} + ${m.detail ? `${esc(m.detail)}` : ''}
  • `).join('') + || `
  • nothing at this level
  • `; + li.appendChild(ul); + })); + // The rail may be closed on a narrow shell; a members request opens it. + if (typeof setRailOpen === 'function') setRailOpen(true); +} + async function renderDashboardByQuestion(slug, stage, host, live) { host.innerHTML = `Reading the questions…`; let questions; @@ -3785,6 +3900,11 @@ async function renderDashboardByQuestion(slug, stage, host, live) { metric: n.dataset.metric || '', summary: n.dataset.summary || '', when: n.dataset.when || '', })); }); + host.querySelectorAll('[data-members]').forEach((n) => { + n.addEventListener('click', () => openMembers({ + slug, analysisId: n.dataset.members, metric: n.dataset.metric || '', title: n.dataset.title || n.dataset.members, + })); + }); host.querySelectorAll('[data-goto-worklist]').forEach((b) => b.addEventListener('click', () => { state.stage = 'investigation'; writeUrl(); diff --git a/packages/resource-explorer/resource_explorer/web/static/next/feedback.js b/packages/resource-explorer/resource_explorer/web/static/next/feedback.js new file mode 100644 index 000000000..5d181a4f0 --- /dev/null +++ b/packages/resource-explorer/resource_explorer/web/static/next/feedback.js @@ -0,0 +1,221 @@ +// Floating "Feedback" button + modal for the /next UI. +// +// Deliberately independent of app.js and re-api.js (another agent is editing +// app.js concurrently) — plain `fetch`, no shared session-id helper, no +// imported ApiError. Self-initialises on import: builds the button, appends +// it, and lazily builds the modal the first time it's opened. +// +// Field names below are read from resource_explorer/web/routes/feedback.py's +// FeedbackSubmission model — verify there before changing any key. + +const CATEGORIES = [ + { value: '', label: 'Category (optional)' }, + { value: 'bug', label: 'Bug' }, + { value: 'confusing', label: 'Confusing' }, + { value: 'suggestion', label: 'Suggestion' }, + { value: 'praise', label: 'Praise' }, +]; + +// Per-tab session id, namespaced separately from the old UI's `re_session_id` +// so the two shells never collide if someone has both open. +const SESSION_KEY = 're_next_session_id'; +function _sessionId() { + let id = sessionStorage.getItem(SESSION_KEY); + if (!id) { + id = 'next-' + Math.random().toString(36).slice(2) + Date.now().toString(36); + sessionStorage.setItem(SESSION_KEY, id); + } + return id; +} + +function _escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +let _rating = 0; +let _modalEl = null; + +function _starsHtml() { + let out = ''; + for (let i = 1; i <= 5; i++) { + out += ``; + } + return out; +} + +function _renderStars() { + const wrap = _modalEl.querySelector('#fb-stars'); + if (wrap) wrap.innerHTML = _starsHtml(); +} + +function _buildModal() { + const overlay = document.createElement('div'); + overlay.id = 'fb-modal-overlay'; + overlay.className = 'fixed inset-0 z-[90] flex items-center justify-center'; + overlay.style.background = 'rgba(27,26,25,0.55)'; + // `hidden` attribute loses to the `flex` utility (display:flex wins over + // preflight's [hidden]); the `hidden` CLASS is ordered after `flex` and wins. + overlay.classList.add('hidden'); + + overlay.innerHTML = ` + + `; + + document.body.appendChild(overlay); + _modalEl = overlay; + + overlay.addEventListener('mousedown', (e) => { + if (e.target === overlay) closeFeedbackModal(); + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && _modalEl && !_modalEl.classList.contains('hidden')) closeFeedbackModal(); + }); + + return overlay; +} + +function _showError(msg) { + const err = _modalEl.querySelector('#fb-error'); + if (!err) return; + err.textContent = msg; + err.hidden = false; +} + +function openFeedbackModal() { + if (!_modalEl) _buildModal(); + // Reset form state on every open — also undoes a previous submission's + // "Thank you" message if the modal is reopened quickly. + _rating = 0; + _resetForm(); + _modalEl.classList.remove('hidden'); + const textarea = _modalEl.querySelector('#fb-message'); + if (textarea) textarea.focus(); +} + +function _resetForm() { + if (!_modalEl) return; + const body = _modalEl.querySelector('#fb-body'); + // Rebuild the body fresh in case a previous submission replaced it with + // the "Thank you" message. + body.innerHTML = ` +
    +
    ${_starsHtml()}
    +
    + + + + + + +
    + + +
    + `; + body.querySelector('#fb-stars').addEventListener('click', (e) => { + const btn = e.target.closest('.fb-star'); + if (!btn) return; + const n = Number(btn.dataset.star); + _rating = _rating === n ? 0 : n; + _renderStars(); + }); + body.querySelector('#fb-cancel').addEventListener('click', closeFeedbackModal); + body.querySelector('#fb-send').addEventListener('click', submitFeedback); +} + +function closeFeedbackModal() { + if (_modalEl) _modalEl.classList.add('hidden'); +} + +async function submitFeedback() { + const body = _modalEl.querySelector('#fb-body'); + const message = body.querySelector('#fb-message').value.trim(); + const email = body.querySelector('#fb-email').value.trim(); + const category = body.querySelector('#fb-category').value; + const wantsResponse = body.querySelector('#fb-wants-response').checked; + const consent = body.querySelector('#fb-consent').checked; + + const err = body.querySelector('#fb-error'); + if (err) err.hidden = true; + + const payload = { + session_id: _sessionId(), + page: location.pathname + location.search, + element_guid: '', + rating: _rating > 0 ? _rating : null, + category, + message, + email, + wants_response: wantsResponse, + consent_to_contact: consent, + build_version: '', + user_agent: navigator.userAgent, + viewport: `${window.innerWidth}x${window.innerHeight}`, + locale: navigator.language || '', + }; + + try { + const res = await fetch('/api/feedback', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (res.status === 401) { + _showError('Sign in to send feedback'); + return; + } + if (!res.ok) { + let detail = `HTTP ${res.status}`; + try { + const data = await res.json(); + if (data && data.detail) detail = data.detail; + } catch { + // body wasn't JSON — keep the plain status message + } + _showError(detail); + return; + } + + // Success — replace the form body with a single confirmation line, then + // auto-close so the modal doesn't linger. + body.innerHTML = `

    Thank you — recorded.

    `; + setTimeout(closeFeedbackModal, 1500); + } catch (e) { + _showError(_escapeHtml(e && e.message ? e.message : 'Could not reach the server')); + } +} + +function _buildButton() { + const btn = document.createElement('button'); + btn.id = 'next-feedback-btn'; + btn.type = 'button'; + btn.textContent = 'Feedback'; + btn.className = + 'fixed z-40 cursor-pointer rounded-sm border border-accent bg-chrome px-s3 py-[6px] ' + + 'text-chip text-accent-on-dark'; + btn.style.bottom = '16px'; + btn.style.right = '16px'; + btn.addEventListener('click', openFeedbackModal); + document.body.appendChild(btn); +} + +_buildButton(); diff --git a/packages/resource-explorer/resource_explorer/web/static/next/index.html b/packages/resource-explorer/resource_explorer/web/static/next/index.html index 22a43cffa..cb34645db 100644 --- a/packages/resource-explorer/resource_explorer/web/static/next/index.html +++ b/packages/resource-explorer/resource_explorer/web/static/next/index.html @@ -422,6 +422,7 @@ + diff --git a/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css b/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css index 52c69b0e4..a71b89b95 100644 --- a/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css +++ b/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SF Mono,Menlo,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.z-50{z-index:50}.m-0{margin:0}.my-s3{margin-top:13.8px;margin-bottom:13.8px}.mb-\[3px\]{margin-bottom:3px}.mb-\[4px\]{margin-bottom:4px}.mb-\[7px\]{margin-bottom:7px}.mb-s1{margin-bottom:4.6px}.mb-s2{margin-bottom:9.2px}.mb-s3{margin-bottom:13.8px}.mb-s4{margin-bottom:18.4px}.ml-\[22px\]{margin-left:22px}.ml-auto{margin-left:auto}.ml-s2{margin-left:9.2px}.ml-s3{margin-left:13.8px}.mr-1{margin-right:.25rem}.mr-s3{margin-right:13.8px}.mt-\[10px\]{margin-top:10px}.mt-\[2px\]{margin-top:2px}.mt-\[3px\]{margin-top:3px}.mt-\[4px\]{margin-top:4px}.mt-\[5px\]{margin-top:5px}.mt-\[6px\]{margin-top:6px}.mt-\[6vh\]{margin-top:6vh}.mt-\[7px\]{margin-top:7px}.mt-s1{margin-top:4.6px}.mt-s2{margin-top:9.2px}.mt-s3{margin-top:13.8px}.mt-s4{margin-top:18.4px}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.\!grid{display:grid!important}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-\[14px\]{height:14px}.h-\[9px\]{height:9px}.h-full{height:100%}.h-px{height:1px}.min-h-0{min-height:0}.min-h-\[320px\]{min-height:320px}.w-\[13px\]{width:13px}.w-\[160px\]{width:160px}.w-\[16px\]{width:16px}.w-\[18px\]{width:18px}.w-\[22px\]{width:22px}.w-\[26px\]{width:26px}.w-\[34px\]{width:34px}.w-\[42\%\]{width:42%}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[60ch\]{max-width:60ch}.max-w-\[640px\]{max-width:640px}.max-w-\[70ch\]{max-width:70ch}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize{resize:both}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.items-start{align-items:flex-start}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-\[1px\]{gap:1px}.gap-\[2px\]{gap:2px}.gap-\[4px\]{gap:4px}.gap-\[5px\]{gap:5px}.gap-\[6px\]{gap:6px}.gap-\[9px\]{gap:9px}.gap-s2{gap:9.2px}.gap-s3{gap:13.8px}.gap-s4{gap:18.4px}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.rounded{border-radius:.25rem}.rounded-\[2px\]{border-radius:2px}.rounded-pill{border-radius:11px}.rounded-sm{border-radius:2px}.border{border-width:1px}.border-0{border-width:0}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l{border-left-width:1px}.border-l-2{border-left-width:2px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-accent{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.border-accent-deep{--tw-border-opacity:1;border-color:rgb(90 59 10/var(--tw-border-opacity,1))}.border-chrome-line{--tw-border-opacity:1;border-color:rgb(68 65 65/var(--tw-border-opacity,1))}.border-chrome-line-soft{--tw-border-opacity:1;border-color:rgb(45 43 43/var(--tw-border-opacity,1))}.border-chrome-muted{--tw-border-opacity:1;border-color:rgb(155 151 151/var(--tw-border-opacity,1))}.border-rule{border-color:rgba(32,31,29,.16)}.border-rule-strong{--tw-border-opacity:1;border-color:rgb(142 138 138/var(--tw-border-opacity,1))}.border-state-gap{--tw-border-opacity:1;border-color:rgb(91 74 156/var(--tw-border-opacity,1))}.border-state-warn{--tw-border-opacity:1;border-color:rgb(156 66 18/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.bg-\[rgba\(168\2c 113\2c 42\2c \.07\)\]{background-color:rgba(168,113,42,.07)}.bg-\[rgba\(32\2c 31\2c 29\2c \.03\)\]{background-color:rgba(32,31,29,.03)}.bg-black\/40{background-color:rgba(0,0,0,.4)}.bg-chrome{--tw-bg-opacity:1;background-color:rgb(27 26 25/var(--tw-bg-opacity,1))}.bg-chrome-alt{--tw-bg-opacity:1;background-color:rgb(32 31 29/var(--tw-bg-opacity,1))}.bg-chrome-surface{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.bg-paper{--tw-bg-opacity:1;background-color:rgb(243 242 242/var(--tw-bg-opacity,1))}.bg-paper-surface{--tw-bg-opacity:1;background-color:rgb(234 233 233/var(--tw-bg-opacity,1))}.bg-rule{background-color:rgba(32,31,29,.16)}.bg-transparent{background-color:transparent}.p-0{padding:0}.p-\[6px\]{padding:6px}.p-s2{padding:9.2px}.p-s3{padding:13.8px}.p-s4{padding:18.4px}.px-0{padding-left:0;padding-right:0}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-\[10px\]{padding-left:10px;padding-right:10px}.px-\[5px\]{padding-left:5px;padding-right:5px}.px-\[6px\]{padding-left:6px;padding-right:6px}.px-\[9px\]{padding-left:9px;padding-right:9px}.px-s3{padding-left:13.8px;padding-right:13.8px}.py-\[10px\]{padding-top:10px;padding-bottom:10px}.py-\[1px\]{padding-top:1px;padding-bottom:1px}.py-\[2px\]{padding-top:2px;padding-bottom:2px}.py-\[3px\]{padding-top:3px;padding-bottom:3px}.py-\[4px\]{padding-top:4px;padding-bottom:4px}.py-\[5px\]{padding-top:5px;padding-bottom:5px}.py-\[7px\]{padding-top:7px;padding-bottom:7px}.py-\[9px\]{padding-top:9px;padding-bottom:9px}.py-s2{padding-top:9.2px;padding-bottom:9.2px}.py-s3{padding-top:13.8px;padding-bottom:13.8px}.py-s4{padding-top:18.4px;padding-bottom:18.4px}.pb-\[1px\]{padding-bottom:1px}.pb-\[2px\]{padding-bottom:2px}.pb-\[4px\]{padding-bottom:4px}.pb-s2{padding-bottom:9.2px}.pb-s3{padding-bottom:13.8px}.pl-s2{padding-left:9.2px}.pr-s3{padding-right:13.8px}.pt-\[9px\]{padding-top:9px}.pt-s1{padding-top:4.6px}.pt-s2{padding-top:9.2px}.text-left{text-align:left}.text-right{text-align:right}.align-bottom{vertical-align:bottom}.align-\[-2px\]{vertical-align:-2px}.font-body{font-family:Lora,Georgia,serif}.font-diagram{font-family:ui-monospace,SF Mono,Menlo,monospace}.font-heading{font-family:Cormorant Garamond,Georgia,serif}.font-mono{font-family:ui-monospace,SF Mono,Menlo,monospace}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.text-\[17px\]{font-size:17px}.text-answer{font-size:.9375rem;line-height:1.55}.text-brand{font-size:1.1875rem;line-height:1.2}.text-caps{font-size:.75rem;line-height:1.3}.text-caveat{font-size:.9375rem;line-height:1.55}.text-chip{font-size:.8125rem;line-height:1.3}.text-intent{font-size:.875rem;line-height:1.2}.text-name{font-size:1.625rem;line-height:1.15}.text-provenance{font-size:.8125rem;line-height:1.45}.text-question{font-size:1.0625rem;line-height:1.25}.text-resource{font-size:.8125rem;line-height:1.35}.text-subtab{font-size:.875rem;line-height:1.2}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.leading-\[1\.95\]{line-height:1.95}.tracking-caps{letter-spacing:.09em}.tracking-normal{letter-spacing:0}.text-accent{--tw-text-opacity:1;color:rgb(182 130 53/var(--tw-text-opacity,1))}.text-accent-ink{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.text-accent-on-dark{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.text-chrome-ink{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.text-chrome-muted{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.text-ink{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.text-ink-muted{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.text-rule-strong{--tw-text-opacity:1;color:rgb(142 138 138/var(--tw-text-opacity,1))}.text-state-gap{--tw-text-opacity:1;color:rgb(91 74 156/var(--tw-text-opacity,1))}.text-state-gap-on-dark{--tw-text-opacity:1;color:rgb(169 154 224/var(--tw-text-opacity,1))}.text-state-ok{--tw-text-opacity:1;color:rgb(29 107 63/var(--tw-text-opacity,1))}.text-state-ok-on-dark{--tw-text-opacity:1;color:rgb(116 198 141/var(--tw-text-opacity,1))}.text-state-warn{--tw-text-opacity:1;color:rgb(156 66 18/var(--tw-text-opacity,1))}.text-state-warn-on-dark{--tw-text-opacity:1;color:rgb(242 162 106/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-dotted{text-decoration-style:dotted}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-100{opacity:1}.shadow-lg{--tw-shadow:0 12px 32px rgba(45,43,43,.22);--tw-shadow-colored:0 12px 32px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.placeholder\:text-chrome-muted::-moz-placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.placeholder\:text-chrome-muted::placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.last\:border-0:last-child{border-width:0}.hover\:border-accent:hover{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.hover\:bg-\[rgba\(32\2c 31\2c 29\2c \.05\)\]:hover{background-color:rgba(32,31,29,.05)}.hover\:bg-accent-tint:hover{--tw-bg-opacity:1;background-color:rgb(255 243 228/var(--tw-bg-opacity,1))}.hover\:bg-chrome-surface:hover{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.hover\:text-accent-ink:hover{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.hover\:text-accent-on-dark:hover{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.hover\:text-chrome-ink:hover{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.hover\:text-ink:hover{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.focus-visible\:text-chrome-ink:focus-visible{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SF Mono,Menlo,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.z-40{z-index:40}.z-50{z-index:50}.z-\[90\]{z-index:90}.m-0{margin:0}.my-s3{margin-top:13.8px;margin-bottom:13.8px}.mb-\[2px\]{margin-bottom:2px}.mb-\[3px\]{margin-bottom:3px}.mb-\[4px\]{margin-bottom:4px}.mb-\[6px\]{margin-bottom:6px}.mb-\[7px\]{margin-bottom:7px}.mb-s1{margin-bottom:4.6px}.mb-s2{margin-bottom:9.2px}.mb-s3{margin-bottom:13.8px}.mb-s4{margin-bottom:18.4px}.ml-\[22px\]{margin-left:22px}.ml-auto{margin-left:auto}.ml-s2{margin-left:9.2px}.ml-s3{margin-left:13.8px}.mr-1{margin-right:.25rem}.mr-s3{margin-right:13.8px}.mt-\[10px\]{margin-top:10px}.mt-\[2px\]{margin-top:2px}.mt-\[3px\]{margin-top:3px}.mt-\[4px\]{margin-top:4px}.mt-\[5px\]{margin-top:5px}.mt-\[6px\]{margin-top:6px}.mt-\[6vh\]{margin-top:6vh}.mt-\[7px\]{margin-top:7px}.mt-s1{margin-top:4.6px}.mt-s2{margin-top:9.2px}.mt-s3{margin-top:13.8px}.mt-s4{margin-top:18.4px}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.\!grid{display:grid!important}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-\[14px\]{height:14px}.h-\[9px\]{height:9px}.h-full{height:100%}.h-px{height:1px}.min-h-0{min-height:0}.min-h-\[320px\]{min-height:320px}.w-\[13px\]{width:13px}.w-\[160px\]{width:160px}.w-\[16px\]{width:16px}.w-\[18px\]{width:18px}.w-\[22px\]{width:22px}.w-\[26px\]{width:26px}.w-\[34px\]{width:34px}.w-\[42\%\]{width:42%}.w-\[min\(380px\2c 92vw\)\]{width:min(380px,92vw)}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[60ch\]{max-width:60ch}.max-w-\[640px\]{max-width:640px}.max-w-\[70ch\]{max-width:70ch}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize-y{resize:vertical}.resize{resize:both}.scroll-mt-\[8px\]{scroll-margin-top:8px}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.items-start{align-items:flex-start}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-\[1px\]{gap:1px}.gap-\[2px\]{gap:2px}.gap-\[4px\]{gap:4px}.gap-\[5px\]{gap:5px}.gap-\[6px\]{gap:6px}.gap-\[9px\]{gap:9px}.gap-s1{gap:4.6px}.gap-s2{gap:9.2px}.gap-s3{gap:13.8px}.gap-s4{gap:18.4px}.gap-x-s3{-moz-column-gap:13.8px;column-gap:13.8px}.gap-y-\[2px\]{row-gap:2px}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-\[2px\]{border-radius:2px}.rounded-pill{border-radius:11px}.rounded-sm{border-radius:2px}.border{border-width:1px}.border-0{border-width:0}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l{border-left-width:1px}.border-l-2{border-left-width:2px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-accent{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.border-accent-deep{--tw-border-opacity:1;border-color:rgb(90 59 10/var(--tw-border-opacity,1))}.border-chrome-line{--tw-border-opacity:1;border-color:rgb(68 65 65/var(--tw-border-opacity,1))}.border-chrome-line-soft{--tw-border-opacity:1;border-color:rgb(45 43 43/var(--tw-border-opacity,1))}.border-chrome-muted{--tw-border-opacity:1;border-color:rgb(155 151 151/var(--tw-border-opacity,1))}.border-rule{border-color:rgba(32,31,29,.16)}.border-rule-strong{--tw-border-opacity:1;border-color:rgb(142 138 138/var(--tw-border-opacity,1))}.border-state-gap{--tw-border-opacity:1;border-color:rgb(91 74 156/var(--tw-border-opacity,1))}.border-state-warn{--tw-border-opacity:1;border-color:rgb(156 66 18/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.bg-\[rgba\(168\2c 113\2c 42\2c \.07\)\]{background-color:rgba(168,113,42,.07)}.bg-\[rgba\(32\2c 31\2c 29\2c \.03\)\]{background-color:rgba(32,31,29,.03)}.bg-black\/40{background-color:rgba(0,0,0,.4)}.bg-chrome{--tw-bg-opacity:1;background-color:rgb(27 26 25/var(--tw-bg-opacity,1))}.bg-chrome-alt{--tw-bg-opacity:1;background-color:rgb(32 31 29/var(--tw-bg-opacity,1))}.bg-chrome-surface{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.bg-paper{--tw-bg-opacity:1;background-color:rgb(243 242 242/var(--tw-bg-opacity,1))}.bg-paper-surface{--tw-bg-opacity:1;background-color:rgb(234 233 233/var(--tw-bg-opacity,1))}.bg-rule{background-color:rgba(32,31,29,.16)}.bg-transparent{background-color:transparent}.p-0{padding:0}.p-\[6px\]{padding:6px}.p-s2{padding:9.2px}.p-s3{padding:13.8px}.p-s4{padding:18.4px}.px-0{padding-left:0;padding-right:0}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-\[10px\]{padding-left:10px;padding-right:10px}.px-\[1px\]{padding-left:1px;padding-right:1px}.px-\[4px\]{padding-left:4px;padding-right:4px}.px-\[5px\]{padding-left:5px;padding-right:5px}.px-\[6px\]{padding-left:6px;padding-right:6px}.px-\[9px\]{padding-left:9px;padding-right:9px}.px-s2{padding-left:9.2px;padding-right:9.2px}.px-s3{padding-left:13.8px;padding-right:13.8px}.py-\[10px\]{padding-top:10px;padding-bottom:10px}.py-\[1px\]{padding-top:1px;padding-bottom:1px}.py-\[2px\]{padding-top:2px;padding-bottom:2px}.py-\[3px\]{padding-top:3px;padding-bottom:3px}.py-\[4px\]{padding-top:4px;padding-bottom:4px}.py-\[5px\]{padding-top:5px;padding-bottom:5px}.py-\[6px\]{padding-top:6px;padding-bottom:6px}.py-\[7px\]{padding-top:7px;padding-bottom:7px}.py-\[9px\]{padding-top:9px;padding-bottom:9px}.py-s2{padding-top:9.2px;padding-bottom:9.2px}.py-s3{padding-top:13.8px;padding-bottom:13.8px}.py-s4{padding-top:18.4px;padding-bottom:18.4px}.pb-\[1px\]{padding-bottom:1px}.pb-\[2px\]{padding-bottom:2px}.pb-\[4px\]{padding-bottom:4px}.pb-s1{padding-bottom:4.6px}.pb-s2{padding-bottom:9.2px}.pb-s3{padding-bottom:13.8px}.pl-\[22px\]{padding-left:22px}.pl-s2{padding-left:9.2px}.pl-s3{padding-left:13.8px}.pr-s3{padding-right:13.8px}.pt-\[9px\]{padding-top:9px}.pt-s1{padding-top:4.6px}.pt-s2{padding-top:9.2px}.pt-s3{padding-top:13.8px}.text-left{text-align:left}.text-right{text-align:right}.align-bottom{vertical-align:bottom}.align-\[-2px\]{vertical-align:-2px}.font-body{font-family:Lora,Georgia,serif}.font-diagram{font-family:ui-monospace,SF Mono,Menlo,monospace}.font-heading{font-family:Cormorant Garamond,Georgia,serif}.font-mono{font-family:ui-monospace,SF Mono,Menlo,monospace}.text-\[1\.1rem\]{font-size:1.1rem}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.text-\[17px\]{font-size:17px}.text-answer{font-size:.9375rem;line-height:1.55}.text-brand{font-size:1.1875rem;line-height:1.2}.text-caps{font-size:.75rem;line-height:1.3}.text-caveat{font-size:.9375rem;line-height:1.55}.text-chip{font-size:.8125rem;line-height:1.3}.text-intent{font-size:.875rem;line-height:1.2}.text-name{font-size:1.625rem;line-height:1.15}.text-provenance{font-size:.8125rem;line-height:1.45}.text-question{font-size:1.0625rem;line-height:1.25}.text-resource{font-size:.8125rem;line-height:1.35}.text-subtab{font-size:.875rem;line-height:1.2}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.leading-\[1\.95\]{line-height:1.95}.leading-none{line-height:1}.tracking-caps{letter-spacing:.09em}.tracking-normal{letter-spacing:0}.text-accent{--tw-text-opacity:1;color:rgb(182 130 53/var(--tw-text-opacity,1))}.text-accent-ink{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.text-accent-on-dark{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.text-chrome-ink{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.text-chrome-muted{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.text-ink{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.text-ink-muted{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.text-rule-strong{--tw-text-opacity:1;color:rgb(142 138 138/var(--tw-text-opacity,1))}.text-state-gap{--tw-text-opacity:1;color:rgb(91 74 156/var(--tw-text-opacity,1))}.text-state-gap-on-dark{--tw-text-opacity:1;color:rgb(169 154 224/var(--tw-text-opacity,1))}.text-state-ok{--tw-text-opacity:1;color:rgb(29 107 63/var(--tw-text-opacity,1))}.text-state-ok-on-dark{--tw-text-opacity:1;color:rgb(116 198 141/var(--tw-text-opacity,1))}.text-state-warn{--tw-text-opacity:1;color:rgb(156 66 18/var(--tw-text-opacity,1))}.text-state-warn-on-dark{--tw-text-opacity:1;color:rgb(242 162 106/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-dotted{text-decoration-style:dotted}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-100{opacity:1}.shadow-lg{--tw-shadow:0 12px 32px rgba(45,43,43,.22);--tw-shadow-colored:0 12px 32px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.placeholder\:text-chrome-muted::-moz-placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.placeholder\:text-chrome-muted::placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.last\:border-0:last-child{border-width:0}.hover\:border-accent:hover{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.hover\:bg-\[rgba\(32\2c 31\2c 29\2c \.05\)\]:hover{background-color:rgba(32,31,29,.05)}.hover\:bg-accent-tint:hover{--tw-bg-opacity:1;background-color:rgb(255 243 228/var(--tw-bg-opacity,1))}.hover\:bg-chrome-surface:hover{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.hover\:text-accent-ink:hover{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.hover\:text-accent-on-dark:hover{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.hover\:text-chrome-ink:hover{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.hover\:text-ink:hover{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.focus-visible\:text-chrome-ink:focus-visible{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))} \ No newline at end of file diff --git a/packages/resource-explorer/resource_explorer/web/static/re-api.js b/packages/resource-explorer/resource_explorer/web/static/re-api.js index fe99938ec..941480ee3 100644 --- a/packages/resource-explorer/resource_explorer/web/static/re-api.js +++ b/packages/resource-explorer/resource_explorer/web/static/re-api.js @@ -394,6 +394,23 @@ export const getAnalysisTrend = (slug, analysisId, metric = '') => encodeURIComponent(analysisId)}/trend${metric ? `?metric=${encodeURIComponent(metric)}` : ''}`); /** Recent activity for one resource — the runs, with their per-step detail. */ +/* ── Members: the things a count counted ───────────────────────────────── + * + * The measurement popup opens a number's history; this opens its members. + * `scope` is "public" or "all" — purpose decides the default (Maintain wants + * all) — and the response says whether the scope was honoured, since only + * symbols carry a public/internal marker today. + */ +export const getMembers = (slug, analysisId, { metric = '', scope = 'public', limit = 200 } = {}) => { + const qs = new URLSearchParams({ scope, limit: String(limit) }); + if (metric) qs.set('metric', metric); + return get(`/api/projects/${encodeURIComponent(slug)}/members/${encodeURIComponent(analysisId)}?${qs}`); +}; + +export const getMemberChildren = (slug, analysisId, key, { scope = 'public', limit = 200 } = {}) => + get(`/api/projects/${encodeURIComponent(slug)}/members/${encodeURIComponent(analysisId)}/children?${ + new URLSearchParams({ key, scope, limit: String(limit) })}`); + export const getResourceRuns = (slug, limit = 40) => get(`/api/activity/?entity_slug=${encodeURIComponent(slug)}&limit=${limit}`); diff --git a/packages/resource-explorer/tests/test_members.py b/packages/resource-explorer/tests/test_members.py new file mode 100644 index 000000000..f415cebf6 --- /dev/null +++ b/packages/resource-explorer/tests/test_members.py @@ -0,0 +1,85 @@ +"""The things a count counted — resource_explorer/members.py. + +Members are read from the registry, not from the display-shaped results, +because those drop `detail_json` and that is where cve_scan keeps its +advisory ids. These use a fresh SQLite registry and write the rows the +readers read, so every reader is exercised against the shape it consumes. +""" +from __future__ import annotations + +import json + +import pytest + +from resource_explorer.members import children_for, members_for +from resource_explorer.registry import Project, ProjectRegistry + + +@pytest.fixture +def db(tmp_path): + r = ProjectRegistry(db_path=str(tmp_path / "t.db")) + r.add(Project(slug="p", display_name="P", github_url="https://github.com/x/p", description="")) + return r + + +class TestCveMembers: + def test_advisories_come_from_detail_json_grouped_by_package(self, db): + db.upsert_finding("p", "cve_scan", [ + {"check_name": "java:pkg-a", "label": "high", "summary": "2 advisories for pkg-a 1.0: GHSA-1, GHSA-2", + "detail": {"package": "pkg-a", "version": "1.0", "severity": "HIGH", "advisory_ids": ["GHSA-1", "GHSA-2"]}}, + {"check_name": "java:pkg-b", "label": "moderate", "summary": "1 advisory", + "detail": {"package": "pkg-b", "version": "2.0", "severity": "MODERATE", "advisory_ids": ["GHSA-3"]}}, + ], surveyed_at="2026-09-01T00:00:00") + m = members_for(db, "p", "cve_scan").to_dict() + assert m["total"] == 3 + assert [g["name"] for g in m["groups"]] == ["pkg-a 1.0", "pkg-b 2.0"] # severity order + assert [x["name"] for x in m["groups"][0]["members"]] == ["GHSA-1", "GHSA-2"] + assert m["groups"][0]["members"][0]["detail"] == "high" + assert m["source"].endswith("detail_json") + + +class TestSymbolMembers: + def test_scope_public_drops_private_and_says_it_is_inferred(self, db): + from resource_explorer.ingestion.code_symbol_extractor import CodeSymbol + + def sym(path, qn, kind, private): + return CodeSymbol(resource_slug="p", file_path=path, language="python", kind=kind, + name=qn.split(".")[-1], qualified_name=qn, signature="", docstring="", + start_line=1, end_line=2, is_private=private) + db.upsert_code_symbols("p", [ + sym("a.py", "a.public_fn", "function", False), + sym("a.py", "a._hidden", "function", True), + sym("b.py", "b.C", "class", False), + ]) + pub = members_for(db, "p", "api_structure", "symbol_count", scope="public").to_dict() + allm = members_for(db, "p", "api_structure", "symbol_count", scope="all").to_dict() + assert pub["total"] == 2 and allm["total"] == 3 + assert pub["scope_honoured"] is True + assert "inferred" in pub["note"] + # files are the first level; symbols are one level down, on demand + files = {x["name"]: x for g in pub["groups"] for x in g["members"]} + assert set(files) == {"a.py", "b.py"} + kids = children_for(db, "p", "api_structure", files["a.py"]["children_key"], scope="public") + assert [k["name"] for k in kids] == ["a.public_fn"] + kids_all = children_for(db, "p", "api_structure", files["a.py"]["children_key"], scope="all") + assert {k["name"] for k in kids_all} == {"a.public_fn", "a._hidden"} + + +class TestFallbackAndScope: + def test_unknown_analysis_falls_back_to_its_findings_with_detail(self, db): + db.upsert_finding("p", "interface_surface", [ + {"check_name": "cli", "label": "implied", "summary": "from click", "detail": {"framework": "click"}}, + {"check_name": "http_api", "label": "implied", "summary": "from fastapi"}, + ], surveyed_at="2026-09-01T00:00:00") + m = members_for(db, "p", "interface_surface").to_dict() + assert m["total"] == 2 and m["groups"][0]["name"] == "implied" + assert m["groups"][0]["members"][0]["extra"] == {"framework": "click"} + # scope is recorded but not honoured -- the caller can see it was + # asked for and not applied, rather than assume it was + assert m["scope"] == "public" and m["scope_honoured"] is False + + def test_bad_scope_is_coerced_not_trusted(self, db): + assert members_for(db, "p", "interface_surface", scope="everything").to_dict()["scope"] == "public" + + def test_children_of_an_unknown_key_is_empty_not_an_error(self, db): + assert children_for(db, "p", "cve_scan", "nope:x") == [] From bc8a11737750af0b28a57174155ef43095721629 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Fri, 11 Sep 2026 16:37:37 -0500 Subject: [PATCH 06/37] =?UTF-8?q?Rebuild=20tailwind-next.css=20after=20reb?= =?UTF-8?q?asing=20onto=20#32=20=E2=80=94=20compiles=20the=20separation=20?= =?UTF-8?q?classes=20it=20added?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- .../resource_explorer/web/static/next/tailwind-next.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css b/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css index a71b89b95..c017b8b8b 100644 --- a/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css +++ b/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SF Mono,Menlo,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.z-40{z-index:40}.z-50{z-index:50}.z-\[90\]{z-index:90}.m-0{margin:0}.my-s3{margin-top:13.8px;margin-bottom:13.8px}.mb-\[2px\]{margin-bottom:2px}.mb-\[3px\]{margin-bottom:3px}.mb-\[4px\]{margin-bottom:4px}.mb-\[6px\]{margin-bottom:6px}.mb-\[7px\]{margin-bottom:7px}.mb-s1{margin-bottom:4.6px}.mb-s2{margin-bottom:9.2px}.mb-s3{margin-bottom:13.8px}.mb-s4{margin-bottom:18.4px}.ml-\[22px\]{margin-left:22px}.ml-auto{margin-left:auto}.ml-s2{margin-left:9.2px}.ml-s3{margin-left:13.8px}.mr-1{margin-right:.25rem}.mr-s3{margin-right:13.8px}.mt-\[10px\]{margin-top:10px}.mt-\[2px\]{margin-top:2px}.mt-\[3px\]{margin-top:3px}.mt-\[4px\]{margin-top:4px}.mt-\[5px\]{margin-top:5px}.mt-\[6px\]{margin-top:6px}.mt-\[6vh\]{margin-top:6vh}.mt-\[7px\]{margin-top:7px}.mt-s1{margin-top:4.6px}.mt-s2{margin-top:9.2px}.mt-s3{margin-top:13.8px}.mt-s4{margin-top:18.4px}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.\!grid{display:grid!important}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-\[14px\]{height:14px}.h-\[9px\]{height:9px}.h-full{height:100%}.h-px{height:1px}.min-h-0{min-height:0}.min-h-\[320px\]{min-height:320px}.w-\[13px\]{width:13px}.w-\[160px\]{width:160px}.w-\[16px\]{width:16px}.w-\[18px\]{width:18px}.w-\[22px\]{width:22px}.w-\[26px\]{width:26px}.w-\[34px\]{width:34px}.w-\[42\%\]{width:42%}.w-\[min\(380px\2c 92vw\)\]{width:min(380px,92vw)}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[60ch\]{max-width:60ch}.max-w-\[640px\]{max-width:640px}.max-w-\[70ch\]{max-width:70ch}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize-y{resize:vertical}.resize{resize:both}.scroll-mt-\[8px\]{scroll-margin-top:8px}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.items-start{align-items:flex-start}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-\[1px\]{gap:1px}.gap-\[2px\]{gap:2px}.gap-\[4px\]{gap:4px}.gap-\[5px\]{gap:5px}.gap-\[6px\]{gap:6px}.gap-\[9px\]{gap:9px}.gap-s1{gap:4.6px}.gap-s2{gap:9.2px}.gap-s3{gap:13.8px}.gap-s4{gap:18.4px}.gap-x-s3{-moz-column-gap:13.8px;column-gap:13.8px}.gap-y-\[2px\]{row-gap:2px}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-\[2px\]{border-radius:2px}.rounded-pill{border-radius:11px}.rounded-sm{border-radius:2px}.border{border-width:1px}.border-0{border-width:0}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l{border-left-width:1px}.border-l-2{border-left-width:2px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-accent{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.border-accent-deep{--tw-border-opacity:1;border-color:rgb(90 59 10/var(--tw-border-opacity,1))}.border-chrome-line{--tw-border-opacity:1;border-color:rgb(68 65 65/var(--tw-border-opacity,1))}.border-chrome-line-soft{--tw-border-opacity:1;border-color:rgb(45 43 43/var(--tw-border-opacity,1))}.border-chrome-muted{--tw-border-opacity:1;border-color:rgb(155 151 151/var(--tw-border-opacity,1))}.border-rule{border-color:rgba(32,31,29,.16)}.border-rule-strong{--tw-border-opacity:1;border-color:rgb(142 138 138/var(--tw-border-opacity,1))}.border-state-gap{--tw-border-opacity:1;border-color:rgb(91 74 156/var(--tw-border-opacity,1))}.border-state-warn{--tw-border-opacity:1;border-color:rgb(156 66 18/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.bg-\[rgba\(168\2c 113\2c 42\2c \.07\)\]{background-color:rgba(168,113,42,.07)}.bg-\[rgba\(32\2c 31\2c 29\2c \.03\)\]{background-color:rgba(32,31,29,.03)}.bg-black\/40{background-color:rgba(0,0,0,.4)}.bg-chrome{--tw-bg-opacity:1;background-color:rgb(27 26 25/var(--tw-bg-opacity,1))}.bg-chrome-alt{--tw-bg-opacity:1;background-color:rgb(32 31 29/var(--tw-bg-opacity,1))}.bg-chrome-surface{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.bg-paper{--tw-bg-opacity:1;background-color:rgb(243 242 242/var(--tw-bg-opacity,1))}.bg-paper-surface{--tw-bg-opacity:1;background-color:rgb(234 233 233/var(--tw-bg-opacity,1))}.bg-rule{background-color:rgba(32,31,29,.16)}.bg-transparent{background-color:transparent}.p-0{padding:0}.p-\[6px\]{padding:6px}.p-s2{padding:9.2px}.p-s3{padding:13.8px}.p-s4{padding:18.4px}.px-0{padding-left:0;padding-right:0}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-\[10px\]{padding-left:10px;padding-right:10px}.px-\[1px\]{padding-left:1px;padding-right:1px}.px-\[4px\]{padding-left:4px;padding-right:4px}.px-\[5px\]{padding-left:5px;padding-right:5px}.px-\[6px\]{padding-left:6px;padding-right:6px}.px-\[9px\]{padding-left:9px;padding-right:9px}.px-s2{padding-left:9.2px;padding-right:9.2px}.px-s3{padding-left:13.8px;padding-right:13.8px}.py-\[10px\]{padding-top:10px;padding-bottom:10px}.py-\[1px\]{padding-top:1px;padding-bottom:1px}.py-\[2px\]{padding-top:2px;padding-bottom:2px}.py-\[3px\]{padding-top:3px;padding-bottom:3px}.py-\[4px\]{padding-top:4px;padding-bottom:4px}.py-\[5px\]{padding-top:5px;padding-bottom:5px}.py-\[6px\]{padding-top:6px;padding-bottom:6px}.py-\[7px\]{padding-top:7px;padding-bottom:7px}.py-\[9px\]{padding-top:9px;padding-bottom:9px}.py-s2{padding-top:9.2px;padding-bottom:9.2px}.py-s3{padding-top:13.8px;padding-bottom:13.8px}.py-s4{padding-top:18.4px;padding-bottom:18.4px}.pb-\[1px\]{padding-bottom:1px}.pb-\[2px\]{padding-bottom:2px}.pb-\[4px\]{padding-bottom:4px}.pb-s1{padding-bottom:4.6px}.pb-s2{padding-bottom:9.2px}.pb-s3{padding-bottom:13.8px}.pl-\[22px\]{padding-left:22px}.pl-s2{padding-left:9.2px}.pl-s3{padding-left:13.8px}.pr-s3{padding-right:13.8px}.pt-\[9px\]{padding-top:9px}.pt-s1{padding-top:4.6px}.pt-s2{padding-top:9.2px}.pt-s3{padding-top:13.8px}.text-left{text-align:left}.text-right{text-align:right}.align-bottom{vertical-align:bottom}.align-\[-2px\]{vertical-align:-2px}.font-body{font-family:Lora,Georgia,serif}.font-diagram{font-family:ui-monospace,SF Mono,Menlo,monospace}.font-heading{font-family:Cormorant Garamond,Georgia,serif}.font-mono{font-family:ui-monospace,SF Mono,Menlo,monospace}.text-\[1\.1rem\]{font-size:1.1rem}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.text-\[17px\]{font-size:17px}.text-answer{font-size:.9375rem;line-height:1.55}.text-brand{font-size:1.1875rem;line-height:1.2}.text-caps{font-size:.75rem;line-height:1.3}.text-caveat{font-size:.9375rem;line-height:1.55}.text-chip{font-size:.8125rem;line-height:1.3}.text-intent{font-size:.875rem;line-height:1.2}.text-name{font-size:1.625rem;line-height:1.15}.text-provenance{font-size:.8125rem;line-height:1.45}.text-question{font-size:1.0625rem;line-height:1.25}.text-resource{font-size:.8125rem;line-height:1.35}.text-subtab{font-size:.875rem;line-height:1.2}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.leading-\[1\.95\]{line-height:1.95}.leading-none{line-height:1}.tracking-caps{letter-spacing:.09em}.tracking-normal{letter-spacing:0}.text-accent{--tw-text-opacity:1;color:rgb(182 130 53/var(--tw-text-opacity,1))}.text-accent-ink{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.text-accent-on-dark{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.text-chrome-ink{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.text-chrome-muted{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.text-ink{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.text-ink-muted{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.text-rule-strong{--tw-text-opacity:1;color:rgb(142 138 138/var(--tw-text-opacity,1))}.text-state-gap{--tw-text-opacity:1;color:rgb(91 74 156/var(--tw-text-opacity,1))}.text-state-gap-on-dark{--tw-text-opacity:1;color:rgb(169 154 224/var(--tw-text-opacity,1))}.text-state-ok{--tw-text-opacity:1;color:rgb(29 107 63/var(--tw-text-opacity,1))}.text-state-ok-on-dark{--tw-text-opacity:1;color:rgb(116 198 141/var(--tw-text-opacity,1))}.text-state-warn{--tw-text-opacity:1;color:rgb(156 66 18/var(--tw-text-opacity,1))}.text-state-warn-on-dark{--tw-text-opacity:1;color:rgb(242 162 106/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-dotted{text-decoration-style:dotted}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-100{opacity:1}.shadow-lg{--tw-shadow:0 12px 32px rgba(45,43,43,.22);--tw-shadow-colored:0 12px 32px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.placeholder\:text-chrome-muted::-moz-placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.placeholder\:text-chrome-muted::placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.last\:border-0:last-child{border-width:0}.hover\:border-accent:hover{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.hover\:bg-\[rgba\(32\2c 31\2c 29\2c \.05\)\]:hover{background-color:rgba(32,31,29,.05)}.hover\:bg-accent-tint:hover{--tw-bg-opacity:1;background-color:rgb(255 243 228/var(--tw-bg-opacity,1))}.hover\:bg-chrome-surface:hover{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.hover\:text-accent-ink:hover{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.hover\:text-accent-on-dark:hover{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.hover\:text-chrome-ink:hover{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.hover\:text-ink:hover{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.focus-visible\:text-chrome-ink:focus-visible{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SF Mono,Menlo,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.z-40{z-index:40}.z-50{z-index:50}.z-\[90\]{z-index:90}.m-0{margin:0}.my-s3{margin-top:13.8px;margin-bottom:13.8px}.-ml-\[22px\]{margin-left:-22px}.mb-\[2px\]{margin-bottom:2px}.mb-\[3px\]{margin-bottom:3px}.mb-\[4px\]{margin-bottom:4px}.mb-\[6px\]{margin-bottom:6px}.mb-\[7px\]{margin-bottom:7px}.mb-s1{margin-bottom:4.6px}.mb-s2{margin-bottom:9.2px}.mb-s3{margin-bottom:13.8px}.mb-s4{margin-bottom:18.4px}.ml-\[22px\]{margin-left:22px}.ml-auto{margin-left:auto}.ml-s2{margin-left:9.2px}.ml-s3{margin-left:13.8px}.mr-1{margin-right:.25rem}.mr-s3{margin-right:13.8px}.mt-\[10px\]{margin-top:10px}.mt-\[2px\]{margin-top:2px}.mt-\[3px\]{margin-top:3px}.mt-\[4px\]{margin-top:4px}.mt-\[5px\]{margin-top:5px}.mt-\[6px\]{margin-top:6px}.mt-\[6vh\]{margin-top:6vh}.mt-\[7px\]{margin-top:7px}.mt-s1{margin-top:4.6px}.mt-s2{margin-top:9.2px}.mt-s3{margin-top:13.8px}.mt-s4{margin-top:18.4px}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.\!grid{display:grid!important}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-\[14px\]{height:14px}.h-\[9px\]{height:9px}.h-full{height:100%}.h-px{height:1px}.min-h-0{min-height:0}.min-h-\[320px\]{min-height:320px}.w-\[13px\]{width:13px}.w-\[160px\]{width:160px}.w-\[16px\]{width:16px}.w-\[18px\]{width:18px}.w-\[22px\]{width:22px}.w-\[26px\]{width:26px}.w-\[34px\]{width:34px}.w-\[42\%\]{width:42%}.w-\[min\(380px\2c 92vw\)\]{width:min(380px,92vw)}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[60ch\]{max-width:60ch}.max-w-\[640px\]{max-width:640px}.max-w-\[70ch\]{max-width:70ch}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize-y{resize:vertical}.resize{resize:both}.scroll-mt-\[8px\]{scroll-margin-top:8px}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.items-start{align-items:flex-start}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-\[1px\]{gap:1px}.gap-\[2px\]{gap:2px}.gap-\[4px\]{gap:4px}.gap-\[5px\]{gap:5px}.gap-\[6px\]{gap:6px}.gap-\[9px\]{gap:9px}.gap-s1{gap:4.6px}.gap-s2{gap:9.2px}.gap-s3{gap:13.8px}.gap-s4{gap:18.4px}.gap-x-s3{-moz-column-gap:13.8px;column-gap:13.8px}.gap-y-\[2px\]{row-gap:2px}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-\[2px\]{border-radius:2px}.rounded-pill{border-radius:11px}.rounded-sm{border-radius:2px}.border{border-width:1px}.border-0{border-width:0}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l{border-left-width:1px}.border-l-2{border-left-width:2px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-accent{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.border-accent-deep{--tw-border-opacity:1;border-color:rgb(90 59 10/var(--tw-border-opacity,1))}.border-chrome-line{--tw-border-opacity:1;border-color:rgb(68 65 65/var(--tw-border-opacity,1))}.border-chrome-line-soft{--tw-border-opacity:1;border-color:rgb(45 43 43/var(--tw-border-opacity,1))}.border-chrome-muted{--tw-border-opacity:1;border-color:rgb(155 151 151/var(--tw-border-opacity,1))}.border-rule{border-color:rgba(32,31,29,.16)}.border-rule-strong{--tw-border-opacity:1;border-color:rgb(142 138 138/var(--tw-border-opacity,1))}.border-state-gap{--tw-border-opacity:1;border-color:rgb(91 74 156/var(--tw-border-opacity,1))}.border-state-warn{--tw-border-opacity:1;border-color:rgb(156 66 18/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.bg-\[rgba\(168\2c 113\2c 42\2c \.07\)\]{background-color:rgba(168,113,42,.07)}.bg-\[rgba\(32\2c 31\2c 29\2c \.03\)\]{background-color:rgba(32,31,29,.03)}.bg-black\/40{background-color:rgba(0,0,0,.4)}.bg-chrome{--tw-bg-opacity:1;background-color:rgb(27 26 25/var(--tw-bg-opacity,1))}.bg-chrome-alt{--tw-bg-opacity:1;background-color:rgb(32 31 29/var(--tw-bg-opacity,1))}.bg-chrome-surface{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.bg-paper{--tw-bg-opacity:1;background-color:rgb(243 242 242/var(--tw-bg-opacity,1))}.bg-paper-surface{--tw-bg-opacity:1;background-color:rgb(234 233 233/var(--tw-bg-opacity,1))}.bg-rule{background-color:rgba(32,31,29,.16)}.bg-transparent{background-color:transparent}.p-0{padding:0}.p-\[6px\]{padding:6px}.p-s2{padding:9.2px}.p-s3{padding:13.8px}.p-s4{padding:18.4px}.px-0{padding-left:0;padding-right:0}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-\[10px\]{padding-left:10px;padding-right:10px}.px-\[1px\]{padding-left:1px;padding-right:1px}.px-\[4px\]{padding-left:4px;padding-right:4px}.px-\[5px\]{padding-left:5px;padding-right:5px}.px-\[6px\]{padding-left:6px;padding-right:6px}.px-\[9px\]{padding-left:9px;padding-right:9px}.px-s2{padding-left:9.2px;padding-right:9.2px}.px-s3{padding-left:13.8px;padding-right:13.8px}.py-\[10px\]{padding-top:10px;padding-bottom:10px}.py-\[1px\]{padding-top:1px;padding-bottom:1px}.py-\[2px\]{padding-top:2px;padding-bottom:2px}.py-\[3px\]{padding-top:3px;padding-bottom:3px}.py-\[4px\]{padding-top:4px;padding-bottom:4px}.py-\[5px\]{padding-top:5px;padding-bottom:5px}.py-\[6px\]{padding-top:6px;padding-bottom:6px}.py-\[7px\]{padding-top:7px;padding-bottom:7px}.py-\[9px\]{padding-top:9px;padding-bottom:9px}.py-s2{padding-top:9.2px;padding-bottom:9.2px}.py-s3{padding-top:13.8px;padding-bottom:13.8px}.py-s4{padding-top:18.4px;padding-bottom:18.4px}.pb-\[1px\]{padding-bottom:1px}.pb-\[2px\]{padding-bottom:2px}.pb-\[4px\]{padding-bottom:4px}.pb-s1{padding-bottom:4.6px}.pb-s2{padding-bottom:9.2px}.pb-s3{padding-bottom:13.8px}.pl-0{padding-left:0}.pl-\[22px\]{padding-left:22px}.pl-s2{padding-left:9.2px}.pl-s3{padding-left:13.8px}.pr-s3{padding-right:13.8px}.pt-\[9px\]{padding-top:9px}.pt-s1{padding-top:4.6px}.pt-s2{padding-top:9.2px}.pt-s3{padding-top:13.8px}.text-left{text-align:left}.text-right{text-align:right}.align-bottom{vertical-align:bottom}.align-\[-2px\]{vertical-align:-2px}.font-body{font-family:Lora,Georgia,serif}.font-diagram{font-family:ui-monospace,SF Mono,Menlo,monospace}.font-heading{font-family:Cormorant Garamond,Georgia,serif}.font-mono{font-family:ui-monospace,SF Mono,Menlo,monospace}.text-\[1\.1rem\]{font-size:1.1rem}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.text-\[17px\]{font-size:17px}.text-answer{font-size:.9375rem;line-height:1.55}.text-brand{font-size:1.1875rem;line-height:1.2}.text-caps{font-size:.75rem;line-height:1.3}.text-caveat{font-size:.9375rem;line-height:1.55}.text-chip{font-size:.8125rem;line-height:1.3}.text-intent{font-size:.875rem;line-height:1.2}.text-name{font-size:1.625rem;line-height:1.15}.text-provenance{font-size:.8125rem;line-height:1.45}.text-question{font-size:1.0625rem;line-height:1.25}.text-resource{font-size:.8125rem;line-height:1.35}.text-subtab{font-size:.875rem;line-height:1.2}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.leading-\[1\.95\]{line-height:1.95}.leading-none{line-height:1}.tracking-caps{letter-spacing:.09em}.tracking-normal{letter-spacing:0}.text-accent{--tw-text-opacity:1;color:rgb(182 130 53/var(--tw-text-opacity,1))}.text-accent-ink{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.text-accent-on-dark{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.text-chrome-ink{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.text-chrome-muted{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.text-ink{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.text-ink-muted{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.text-rule-strong{--tw-text-opacity:1;color:rgb(142 138 138/var(--tw-text-opacity,1))}.text-state-gap{--tw-text-opacity:1;color:rgb(91 74 156/var(--tw-text-opacity,1))}.text-state-gap-on-dark{--tw-text-opacity:1;color:rgb(169 154 224/var(--tw-text-opacity,1))}.text-state-ok{--tw-text-opacity:1;color:rgb(29 107 63/var(--tw-text-opacity,1))}.text-state-ok-on-dark{--tw-text-opacity:1;color:rgb(116 198 141/var(--tw-text-opacity,1))}.text-state-warn{--tw-text-opacity:1;color:rgb(156 66 18/var(--tw-text-opacity,1))}.text-state-warn-on-dark{--tw-text-opacity:1;color:rgb(242 162 106/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-dotted{text-decoration-style:dotted}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-100{opacity:1}.shadow-lg{--tw-shadow:0 12px 32px rgba(45,43,43,.22);--tw-shadow-colored:0 12px 32px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.placeholder\:text-chrome-muted::-moz-placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.placeholder\:text-chrome-muted::placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.last\:border-0:last-child{border-width:0}.hover\:border-accent:hover{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.hover\:bg-\[rgba\(32\2c 31\2c 29\2c \.05\)\]:hover{background-color:rgba(32,31,29,.05)}.hover\:bg-accent-tint:hover{--tw-bg-opacity:1;background-color:rgb(255 243 228/var(--tw-bg-opacity,1))}.hover\:bg-chrome-surface:hover{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.hover\:text-accent-ink:hover{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.hover\:text-accent-on-dark:hover{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.hover\:text-chrome-ink:hover{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.hover\:text-ink:hover{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.focus-visible\:text-chrome-ink:focus-visible{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))} \ No newline at end of file From d2b110b7c0205b20e235e8df923c1c3384d73eb5 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Fri, 11 Sep 2026 16:53:27 -0500 Subject: [PATCH 07/37] Enrichment: testimony, not paperwork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repo Handoff item 4. Two halves, not one list of eight fields with one Save. The line between them is not arbitrary: a field is a JUDGEMENT if a person's opinion is the value — sensitivity, criticality, intended use, actual use, owner — and an OBSERVATION if a person is supplying a fact about the world — licence, environment, retention. Opinions need an author, a date and a review state. Observations need a source. Judgements come first and get the room; they are the part only a person can supply. Every field saves alone: someone who knows the owner and not the sensitivity can say so and leave. The author and date sit on every judgement without hovering, because testimony without an author is not testimony. The author is stamped by the SERVER from the signed-in identity, never taken from the client — the write model has no author field to forge, and an anonymous write is refused with 401 rather than recorded as nobody's. PATCH .../field does the read-modify-write on the server, so two people setting two fields do not clobber each other the way two whole-document POSTs would. The five fields the old UI's form also reads are mirrored to its flat keys, so the two UIs keep agreeing. Perishability, which needs no per-field configuration: a judgement carries the measurements that were on screen when it was made. When one of those moves, the judgement is not invalidated — it gets a flag that says what moved: "⚠ review — evidence moved: cve_scan". A specific, answerable prompt, not a staleness timer. The rail marks the same event from the other side: "new since you judged" on the analysis that moved. Evidence sits in the rail as material, never as proposals. No "apply suggestion". The one exception is a fact a survey already established — the licence — offered to confirm, with its source, into the observations half. It offers "Apache License 2.0", the licence, not "permissive", its risk tier; the finding's label is the tier and the fact is before the dash. The seven Human-Supplied catalog questions render under both halves as "what only you can answer", each saving alone as before. And the one sentence worth pre-empting is on the pane: nothing here is written to the catalogue until Curate. Not built: owner candidates from the contributor data ("2 candidates ›"). The interim owner is the investigator, marked so. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- .../resource_explorer/web/routes/context.py | 88 +++++++- .../resource_explorer/web/static/next/app.js | 203 ++++++++++++++++++ .../web/static/next/tailwind-next.css | 2 +- .../resource_explorer/web/static/re-api.js | 13 ++ .../tests/test_enrichment_fields.py | 105 +++++++++ 5 files changed, 408 insertions(+), 3 deletions(-) create mode 100644 packages/resource-explorer/tests/test_enrichment_fields.py diff --git a/packages/resource-explorer/resource_explorer/web/routes/context.py b/packages/resource-explorer/resource_explorer/web/routes/context.py index f5cb673f3..f1cc1e54d 100644 --- a/packages/resource-explorer/resource_explorer/web/routes/context.py +++ b/packages/resource-explorer/resource_explorer/web/routes/context.py @@ -3,8 +3,10 @@ from datetime import datetime, timezone -from fastapi import APIRouter -from pydantic import BaseModel +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +from resource_explorer.auth import get_current_user from resource_explorer.activity_logger import log_rfa from resource_explorer.registry import ProjectRegistry @@ -39,6 +41,47 @@ class QuestionAnswer(BaseModel): answered_at: str = "" +class EnrichmentField(BaseModel): + """One enrichment field as testimony, not paperwork. + + Two kinds, and the kind is not configuration — a field is a JUDGEMENT if + a person's opinion is the value (sensitivity, criticality, intended use, + actual use, owner, notes) and an OBSERVATION if a person is supplying a + fact about the world (licence, environment, retention). Judgements are + perishable: they carry an author, a date, and the measurements that were + on screen when they were made, so that when those measurements move the + judgement is not invalidated but FLAGGED for review, and the flag can say + what moved. Observations carry a source instead. + + `author` and `set_at` are stamped by the server from the signed-in + identity, never taken from the client: testimony without an author is + not testimony, and an author the client asserts is not an author. + """ + value: str = "" + kind: str = "judgement" # judgement | observation + author: str = "" # server-stamped + set_at: str = "" # server-stamped, ISO + source: str = "" # observations: where the fact came from ("license_classification", "user") + # analysis_id -> last_run_at, as shown when the judgement was made. + evidence: dict[str, str] = Field(default_factory=dict) + interim: bool = False # owner only: the investigator standing in + + +class FieldWrite(BaseModel): + key: str + value: str = "" + kind: str = "judgement" + source: str = "" + evidence: dict[str, str] = Field(default_factory=dict) + interim: bool = False + + +# The flat keys `/`'s form reads, mirrored from the enrichment record so the +# two UIs keep agreeing on the fields they share. +_MIRROR = {"sensitivity": "sensitivity", "environment": "environment", + "owner": "org_owner", "intended_use": "purpose", "notes": "notes"} + + class ContextData(BaseModel): environment: str = "" # production | staging | dev | research | archive | unknown org_owner: str = "" @@ -56,6 +99,10 @@ class ContextData(BaseModel): # catalog actually asks a human — dependencies, cost, skills, monitoring, # security, governance, estate fit — which had nowhere to be stored. question_answers: dict[str, QuestionAnswer] = {} + # Enrichment as testimony (2026-09-11): each field with its kind, its + # author and date, and the evidence on screen when a judgement was made. + # See EnrichmentField. Saved one field at a time through PATCH .../field. + enrichment: dict[str, EnrichmentField] = {} @router.get("/{entity_type}/{slug}") @@ -100,3 +147,40 @@ def save_context(entity_type: str, slug: str, data: ContextData) -> dict: "context": context, "rfa_fields": rfa_fields, } + + +@router.patch("/{entity_type}/{slug}/field") +def save_field(entity_type: str, slug: str, write: FieldWrite, request: Request) -> dict: + """Save ONE enrichment field. Eight independent facts should not share a + Save: a person who knows the owner and not the sensitivity can say so + and leave. Read-modify-write on the server, so two people setting two + fields do not clobber each other the way two whole-document POSTs would. + + The author is the signed-in user, stamped here. Anonymous writes are + refused with 401 rather than recorded as nobody's: a judgement with no + author is the thing this model exists to prevent. + """ + user = get_current_user(request) + author = (user or {}).get("user_id") or (user or {}).get("sub") or (user or {}).get("username") or "" + if not author: + raise HTTPException(status_code=401, detail="Sign in to record enrichment — a judgement needs an author.") + if write.kind not in ("judgement", "observation"): + raise HTTPException(status_code=422, detail="kind must be judgement or observation") + key = write.key.strip().lower().replace(" ", "_") + if not key or len(key) > 64: + raise HTTPException(status_code=422, detail="key is required") + + registry = ProjectRegistry() + context = registry.get_context(entity_type, slug) or {} + fields = dict(context.get("enrichment") or {}) + fields[key] = EnrichmentField( + value=write.value.strip(), kind=write.kind, author=author, + set_at=datetime.now(timezone.utc).isoformat(), source=write.source.strip(), + evidence=dict(write.evidence), interim=bool(write.interim), + ).model_dump() + context["enrichment"] = fields + if key in _MIRROR: + context[_MIRROR[key]] = write.value.strip() + context["updated_at"] = datetime.now(timezone.utc).isoformat() + registry.save_context(entity_type, slug, context) + return {"key": key, "field": fields[key]} diff --git a/packages/resource-explorer/resource_explorer/web/static/next/app.js b/packages/resource-explorer/resource_explorer/web/static/next/app.js index 6e9fd0cfa..484bde595 100644 --- a/packages/resource-explorer/resource_explorer/web/static/next/app.js +++ b/packages/resource-explorer/resource_explorer/web/static/next/app.js @@ -66,6 +66,7 @@ import { setWorkingSetHidden, getContext, questionKey, + saveEnrichmentField, saveQuestionAnswer, } from '/static/re-api.js'; @@ -4464,6 +4465,7 @@ async function loadPane() {
    +
    `; bindSubTabs(); bindResourceHeader(); @@ -4492,8 +4494,10 @@ async function loadPane() { try { const ctx = await getContext('repo', slug); state.contextAnswers = ctx?.question_answers || {}; + state.enrichment = ctx?.enrichment || {}; } catch { state.contextAnswers = {}; + state.enrichment = {}; } // The residue count needs the unfiltered total. One extra call, only when @@ -4525,6 +4529,7 @@ async function loadPane() { rows.innerHTML = state.questions.map((q, i) => rowShell(q, i)).join(''); wireHumanAnswers(rows, slug); + if (state.stage === 'enrichment') renderEnrichment(slug); updateAnsweredCount(); renderLegend(); @@ -4583,6 +4588,204 @@ function wireHumanAnswers(host, slug) { }); } +/* ── Enrichment: testimony, not paperwork ────────────────────────────────── + * + * Two halves, not one list of eight fields with one Save. The line between + * them is not arbitrary: a field is a JUDGEMENT if a person's opinion is the + * value — sensitivity, criticality, intended use, actual use, owner — and an + * OBSERVATION if a person is supplying a fact about the world — licence, + * environment, retention. Opinions need an author, a date and a review + * state. Observations need a source. + * + * Judgements come first and get the room: they are the part only a person + * can supply. Every field saves alone — someone who knows the owner and not + * the sensitivity can say so and leave. The author and date sit on every + * judgement without hovering, because testimony without an author is not + * testimony, and because that is what makes review meaningful later. + * + * Perishability, which needs no per-field configuration: a judgement carries + * the measurements that were on screen when it was made. When one of those + * moves, the judgement is not invalidated — it gets a flag that says what + * moved. "⚠ review — evidence moved: cve_scan" is a specific, answerable + * prompt, not a staleness timer. + * + * Evidence sits in the rail as MATERIAL, never as proposals. No "apply + * suggestion". The one exception is a fact a survey already established — + * the licence — which is offered to confirm, with its source, into the + * observations half. + * + * Nothing here is written to the catalogue until Curate. That sentence is + * the one misconception worth pre-empting, and it is on the pane. + */ +const JUDGEMENTS = [ + { key: 'sensitivity', label: 'Sensitivity', options: ['public', 'internal', 'confidential', 'restricted'] }, + { key: 'criticality', label: 'Criticality', options: ['low', 'important', 'critical'] }, + { key: 'intended_use', label: 'Intended use', placeholder: 'what is this for, here?' }, + { key: 'actual_use', label: 'Actual use', placeholder: 'how is it used today?' }, + { key: 'owner', label: 'Owner', placeholder: 'who answers for it?' }, +]; +const OBSERVATIONS = [ + { key: 'licence', label: 'Licence', fromAnalysis: 'license_classification' }, + { key: 'environment', label: 'Environment', options: ['prod', 'dev', 'test', 'research', 'archive'] }, + { key: 'retention', label: 'Retention', placeholder: 'how long, and by whose rule?' }, +]; +// The analyses whose current state is the evidence for a judgement. +const ENRICHMENT_EVIDENCE = ['interface_surface', 'security_scan', 'chaoss_metrics', 'cve_scan', + 'repository_health', 'license_classification', 'secret_scan', 'documentation_coverage']; + +function evidenceSnapshot() { + const snap = {}; + for (const [id, f] of Object.entries(state.enrichmentFacts || {})) if (f.last_run_at) snap[id] = f.last_run_at; + return snap; +} + +/** Which of a judgement's evidence has moved since it was made. */ +function movedSince(field) { + const moved = []; + for (const [id, at] of Object.entries(field.evidence || {})) { + const now = state.enrichmentFacts?.[id]?.last_run_at; + if (now && now > at) moved.push(id); + } + return moved; +} + +function fieldControlHtml(def, field) { + const v = field?.value || ''; + if (def.options) { + return ``; + } + return ``; +} + +function fieldRowHtml(def, kind) { + const field = (state.enrichment || {})[def.key]; + const moved = field && kind === 'judgement' ? movedSince(field) : []; + const who = field?.author ? `${esc(field.author)}${field.interim ? ' · interim' : ''} · ${esc(ago(field.set_at))}` : ''; + const proposed = def.fromAnalysis && !field?.value ? proposedFrom(def.fromAnalysis) : null; + return `
    +
    ${esc(def.label)}
    +
    +
    ${fieldControlHtml(def, field)} +
    +
    + ${moved.length ? `⚠ review — evidence moved: ${esc(moved.join(', '))} · ` : ''} + ${who || (field?.source ? `from ${esc(field.source)}` : '')} + ${proposed ? `from survey: ${esc(proposed.value)} · + ` : ''} +
    +
    +
    `; +} + +/** A fact a survey already established, offered to confirm — not applied. */ +function proposedFrom(analysisId) { + const f = state.enrichmentFacts?.[analysisId]; + if (!f || f.state !== 'measured') return null; + // The licence itself, not its risk tier: the finding's label is + // "permissive" and its summary is "Apache License 2.0 — Permissive". The + // part before the dash is the fact a person would confirm. + const raw = f.headline || (f.value?.findings || [])[0]?.summary || ''; + const value = String(raw).split(' — ')[0].trim(); + return value ? { value } : null; +} + +async function renderEnrichment(slug) { + const host = $('enrichment-form'); + if (!host) return; + host.innerHTML = `
    Reading the evidence…
    `; + try { + const res = await getBulkFacts([slug], ENRICHMENT_EVIDENCE); + state.enrichmentFacts = Object.fromEntries(((res.subjects || {})[slug] || []).map((f) => [f.analysis_id, f])); + } catch { state.enrichmentFacts = {}; } + if (slug !== state.selectedSlug) return; + + const setJ = JUDGEMENTS.filter((d) => state.enrichment?.[d.key]?.value).length; + host.innerHTML = ` +

    Nothing here is written to the catalogue until you catalogue it (Curate). + What you set here is testimony — yours, dated — and the surveys' findings in the rail are material to read, not answers to accept.

    +
    + What we judge + ${setJ} of ${JUDGEMENTS.length} set · perishable +
    + ${JUDGEMENTS.map((d) => fieldRowHtml(d, 'judgement')).join('')} +
    + What we record + durable +
    + ${OBSERVATIONS.map((d) => fieldRowHtml(d, 'observation')).join('')} +
    What only you can answer
    +
    The catalog's own questions for a person, below — each saves alone.
    `; + + host.querySelectorAll('[data-save]').forEach((b) => b.addEventListener('click', async () => { + const key = b.dataset.save; const kind = b.dataset.kind; + const ctl = host.querySelector(`[data-field="${key}"]`); + const value = (ctl?.value || '').trim(); + b.disabled = true; b.textContent = 'saving…'; + try { + const out = await saveEnrichmentField(slug, key, { + value, kind, evidence: kind === 'judgement' ? evidenceSnapshot() : {}, + interim: key === 'owner' && !value, + source: kind === 'observation' ? 'user' : '', + }); + state.enrichment = { ...(state.enrichment || {}), [key]: out.field }; + renderEnrichment(slug); + } catch (err) { + b.disabled = false; + b.textContent = err.status === 401 ? 'sign in to record' : `not saved: ${err.message}`; + } + })); + host.querySelectorAll('[data-confirm]').forEach((b) => b.addEventListener('click', async () => { + b.disabled = true; b.textContent = 'confirming…'; + try { + const out = await saveEnrichmentField(slug, b.dataset.confirm, { + value: b.dataset.value, kind: 'observation', source: b.dataset.source, + }); + state.enrichment = { ...(state.enrichment || {}), [b.dataset.confirm]: out.field }; + renderEnrichment(slug); + } catch (err) { + b.disabled = false; + b.textContent = err.status === 401 ? 'sign in to record' : `not confirmed: ${err.message}`; + } + })); + renderEnrichmentEvidence(slug); +} + +/** The rail: evidence as material. Each analysis's own sentence, its age, and + * "new since you judged" where its run postdates the latest judgement that + * saw it — the perishability mechanism doing its job at the moment it is + * useful. */ +function renderEnrichmentEvidence(slug) { + const out = $('rail-evidence'); + if (!out) return; + const judged = Object.values(state.enrichment || {}).filter((f) => f.kind === 'judgement' && f.set_at); + const items = ENRICHMENT_EVIDENCE.map((id) => state.enrichmentFacts?.[id]).filter(Boolean); + out.innerHTML = ` +
    + Evidence + material, not proposals +
    + ${items.length ? items.map((f) => { + const g = factGlyph(f.state); + const seen = judged.some((j) => j.evidence?.[f.analysis_id]); + const fresh = judged.some((j) => j.evidence?.[f.analysis_id] && f.last_run_at > j.evidence[f.analysis_id]); + return `
    +
    + ${g.glyph} + ${tnum(esc(f.headline || f.state))}
    +
    ${esc(f.analysis_id)}${ + f.last_run_at ? ` · ${esc(ago(f.last_run_at))}` : ''}${ + fresh ? ` · new since you judged` : seen ? '' : ''}
    +
    `; + }).join('') : `
    No measurements to show yet.
    `} +
    Material to read, not answers to accept. No "apply suggestion".
    `; +} + function rowKey(i) { return `qrow-${i}`; } /* ── Purpose orders; Perspective filters ────────────────────────────────── diff --git a/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css b/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css index c017b8b8b..f571b5f7f 100644 --- a/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css +++ b/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SF Mono,Menlo,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.z-40{z-index:40}.z-50{z-index:50}.z-\[90\]{z-index:90}.m-0{margin:0}.my-s3{margin-top:13.8px;margin-bottom:13.8px}.-ml-\[22px\]{margin-left:-22px}.mb-\[2px\]{margin-bottom:2px}.mb-\[3px\]{margin-bottom:3px}.mb-\[4px\]{margin-bottom:4px}.mb-\[6px\]{margin-bottom:6px}.mb-\[7px\]{margin-bottom:7px}.mb-s1{margin-bottom:4.6px}.mb-s2{margin-bottom:9.2px}.mb-s3{margin-bottom:13.8px}.mb-s4{margin-bottom:18.4px}.ml-\[22px\]{margin-left:22px}.ml-auto{margin-left:auto}.ml-s2{margin-left:9.2px}.ml-s3{margin-left:13.8px}.mr-1{margin-right:.25rem}.mr-s3{margin-right:13.8px}.mt-\[10px\]{margin-top:10px}.mt-\[2px\]{margin-top:2px}.mt-\[3px\]{margin-top:3px}.mt-\[4px\]{margin-top:4px}.mt-\[5px\]{margin-top:5px}.mt-\[6px\]{margin-top:6px}.mt-\[6vh\]{margin-top:6vh}.mt-\[7px\]{margin-top:7px}.mt-s1{margin-top:4.6px}.mt-s2{margin-top:9.2px}.mt-s3{margin-top:13.8px}.mt-s4{margin-top:18.4px}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.\!grid{display:grid!important}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-\[14px\]{height:14px}.h-\[9px\]{height:9px}.h-full{height:100%}.h-px{height:1px}.min-h-0{min-height:0}.min-h-\[320px\]{min-height:320px}.w-\[13px\]{width:13px}.w-\[160px\]{width:160px}.w-\[16px\]{width:16px}.w-\[18px\]{width:18px}.w-\[22px\]{width:22px}.w-\[26px\]{width:26px}.w-\[34px\]{width:34px}.w-\[42\%\]{width:42%}.w-\[min\(380px\2c 92vw\)\]{width:min(380px,92vw)}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[60ch\]{max-width:60ch}.max-w-\[640px\]{max-width:640px}.max-w-\[70ch\]{max-width:70ch}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize-y{resize:vertical}.resize{resize:both}.scroll-mt-\[8px\]{scroll-margin-top:8px}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.items-start{align-items:flex-start}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-\[1px\]{gap:1px}.gap-\[2px\]{gap:2px}.gap-\[4px\]{gap:4px}.gap-\[5px\]{gap:5px}.gap-\[6px\]{gap:6px}.gap-\[9px\]{gap:9px}.gap-s1{gap:4.6px}.gap-s2{gap:9.2px}.gap-s3{gap:13.8px}.gap-s4{gap:18.4px}.gap-x-s3{-moz-column-gap:13.8px;column-gap:13.8px}.gap-y-\[2px\]{row-gap:2px}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-\[2px\]{border-radius:2px}.rounded-pill{border-radius:11px}.rounded-sm{border-radius:2px}.border{border-width:1px}.border-0{border-width:0}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l{border-left-width:1px}.border-l-2{border-left-width:2px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-accent{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.border-accent-deep{--tw-border-opacity:1;border-color:rgb(90 59 10/var(--tw-border-opacity,1))}.border-chrome-line{--tw-border-opacity:1;border-color:rgb(68 65 65/var(--tw-border-opacity,1))}.border-chrome-line-soft{--tw-border-opacity:1;border-color:rgb(45 43 43/var(--tw-border-opacity,1))}.border-chrome-muted{--tw-border-opacity:1;border-color:rgb(155 151 151/var(--tw-border-opacity,1))}.border-rule{border-color:rgba(32,31,29,.16)}.border-rule-strong{--tw-border-opacity:1;border-color:rgb(142 138 138/var(--tw-border-opacity,1))}.border-state-gap{--tw-border-opacity:1;border-color:rgb(91 74 156/var(--tw-border-opacity,1))}.border-state-warn{--tw-border-opacity:1;border-color:rgb(156 66 18/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.bg-\[rgba\(168\2c 113\2c 42\2c \.07\)\]{background-color:rgba(168,113,42,.07)}.bg-\[rgba\(32\2c 31\2c 29\2c \.03\)\]{background-color:rgba(32,31,29,.03)}.bg-black\/40{background-color:rgba(0,0,0,.4)}.bg-chrome{--tw-bg-opacity:1;background-color:rgb(27 26 25/var(--tw-bg-opacity,1))}.bg-chrome-alt{--tw-bg-opacity:1;background-color:rgb(32 31 29/var(--tw-bg-opacity,1))}.bg-chrome-surface{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.bg-paper{--tw-bg-opacity:1;background-color:rgb(243 242 242/var(--tw-bg-opacity,1))}.bg-paper-surface{--tw-bg-opacity:1;background-color:rgb(234 233 233/var(--tw-bg-opacity,1))}.bg-rule{background-color:rgba(32,31,29,.16)}.bg-transparent{background-color:transparent}.p-0{padding:0}.p-\[6px\]{padding:6px}.p-s2{padding:9.2px}.p-s3{padding:13.8px}.p-s4{padding:18.4px}.px-0{padding-left:0;padding-right:0}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-\[10px\]{padding-left:10px;padding-right:10px}.px-\[1px\]{padding-left:1px;padding-right:1px}.px-\[4px\]{padding-left:4px;padding-right:4px}.px-\[5px\]{padding-left:5px;padding-right:5px}.px-\[6px\]{padding-left:6px;padding-right:6px}.px-\[9px\]{padding-left:9px;padding-right:9px}.px-s2{padding-left:9.2px;padding-right:9.2px}.px-s3{padding-left:13.8px;padding-right:13.8px}.py-\[10px\]{padding-top:10px;padding-bottom:10px}.py-\[1px\]{padding-top:1px;padding-bottom:1px}.py-\[2px\]{padding-top:2px;padding-bottom:2px}.py-\[3px\]{padding-top:3px;padding-bottom:3px}.py-\[4px\]{padding-top:4px;padding-bottom:4px}.py-\[5px\]{padding-top:5px;padding-bottom:5px}.py-\[6px\]{padding-top:6px;padding-bottom:6px}.py-\[7px\]{padding-top:7px;padding-bottom:7px}.py-\[9px\]{padding-top:9px;padding-bottom:9px}.py-s2{padding-top:9.2px;padding-bottom:9.2px}.py-s3{padding-top:13.8px;padding-bottom:13.8px}.py-s4{padding-top:18.4px;padding-bottom:18.4px}.pb-\[1px\]{padding-bottom:1px}.pb-\[2px\]{padding-bottom:2px}.pb-\[4px\]{padding-bottom:4px}.pb-s1{padding-bottom:4.6px}.pb-s2{padding-bottom:9.2px}.pb-s3{padding-bottom:13.8px}.pl-0{padding-left:0}.pl-\[22px\]{padding-left:22px}.pl-s2{padding-left:9.2px}.pl-s3{padding-left:13.8px}.pr-s3{padding-right:13.8px}.pt-\[9px\]{padding-top:9px}.pt-s1{padding-top:4.6px}.pt-s2{padding-top:9.2px}.pt-s3{padding-top:13.8px}.text-left{text-align:left}.text-right{text-align:right}.align-bottom{vertical-align:bottom}.align-\[-2px\]{vertical-align:-2px}.font-body{font-family:Lora,Georgia,serif}.font-diagram{font-family:ui-monospace,SF Mono,Menlo,monospace}.font-heading{font-family:Cormorant Garamond,Georgia,serif}.font-mono{font-family:ui-monospace,SF Mono,Menlo,monospace}.text-\[1\.1rem\]{font-size:1.1rem}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.text-\[17px\]{font-size:17px}.text-answer{font-size:.9375rem;line-height:1.55}.text-brand{font-size:1.1875rem;line-height:1.2}.text-caps{font-size:.75rem;line-height:1.3}.text-caveat{font-size:.9375rem;line-height:1.55}.text-chip{font-size:.8125rem;line-height:1.3}.text-intent{font-size:.875rem;line-height:1.2}.text-name{font-size:1.625rem;line-height:1.15}.text-provenance{font-size:.8125rem;line-height:1.45}.text-question{font-size:1.0625rem;line-height:1.25}.text-resource{font-size:.8125rem;line-height:1.35}.text-subtab{font-size:.875rem;line-height:1.2}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.leading-\[1\.95\]{line-height:1.95}.leading-none{line-height:1}.tracking-caps{letter-spacing:.09em}.tracking-normal{letter-spacing:0}.text-accent{--tw-text-opacity:1;color:rgb(182 130 53/var(--tw-text-opacity,1))}.text-accent-ink{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.text-accent-on-dark{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.text-chrome-ink{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.text-chrome-muted{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.text-ink{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.text-ink-muted{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.text-rule-strong{--tw-text-opacity:1;color:rgb(142 138 138/var(--tw-text-opacity,1))}.text-state-gap{--tw-text-opacity:1;color:rgb(91 74 156/var(--tw-text-opacity,1))}.text-state-gap-on-dark{--tw-text-opacity:1;color:rgb(169 154 224/var(--tw-text-opacity,1))}.text-state-ok{--tw-text-opacity:1;color:rgb(29 107 63/var(--tw-text-opacity,1))}.text-state-ok-on-dark{--tw-text-opacity:1;color:rgb(116 198 141/var(--tw-text-opacity,1))}.text-state-warn{--tw-text-opacity:1;color:rgb(156 66 18/var(--tw-text-opacity,1))}.text-state-warn-on-dark{--tw-text-opacity:1;color:rgb(242 162 106/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-dotted{text-decoration-style:dotted}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-100{opacity:1}.shadow-lg{--tw-shadow:0 12px 32px rgba(45,43,43,.22);--tw-shadow-colored:0 12px 32px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.placeholder\:text-chrome-muted::-moz-placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.placeholder\:text-chrome-muted::placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.last\:border-0:last-child{border-width:0}.hover\:border-accent:hover{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.hover\:bg-\[rgba\(32\2c 31\2c 29\2c \.05\)\]:hover{background-color:rgba(32,31,29,.05)}.hover\:bg-accent-tint:hover{--tw-bg-opacity:1;background-color:rgb(255 243 228/var(--tw-bg-opacity,1))}.hover\:bg-chrome-surface:hover{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.hover\:text-accent-ink:hover{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.hover\:text-accent-on-dark:hover{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.hover\:text-chrome-ink:hover{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.hover\:text-ink:hover{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.focus-visible\:text-chrome-ink:focus-visible{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SF Mono,Menlo,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.z-40{z-index:40}.z-50{z-index:50}.z-\[90\]{z-index:90}.m-0{margin:0}.my-s3{margin-top:13.8px;margin-bottom:13.8px}.-ml-\[22px\]{margin-left:-22px}.mb-\[2px\]{margin-bottom:2px}.mb-\[3px\]{margin-bottom:3px}.mb-\[4px\]{margin-bottom:4px}.mb-\[6px\]{margin-bottom:6px}.mb-\[7px\]{margin-bottom:7px}.mb-s1{margin-bottom:4.6px}.mb-s2{margin-bottom:9.2px}.mb-s3{margin-bottom:13.8px}.mb-s4{margin-bottom:18.4px}.ml-\[22px\]{margin-left:22px}.ml-auto{margin-left:auto}.ml-s2{margin-left:9.2px}.ml-s3{margin-left:13.8px}.mr-1{margin-right:.25rem}.mr-s3{margin-right:13.8px}.mt-\[10px\]{margin-top:10px}.mt-\[2px\]{margin-top:2px}.mt-\[3px\]{margin-top:3px}.mt-\[4px\]{margin-top:4px}.mt-\[5px\]{margin-top:5px}.mt-\[6px\]{margin-top:6px}.mt-\[6vh\]{margin-top:6vh}.mt-\[7px\]{margin-top:7px}.mt-s1{margin-top:4.6px}.mt-s2{margin-top:9.2px}.mt-s3{margin-top:13.8px}.mt-s4{margin-top:18.4px}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.\!grid{display:grid!important}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-\[14px\]{height:14px}.h-\[9px\]{height:9px}.h-full{height:100%}.h-px{height:1px}.min-h-0{min-height:0}.min-h-\[320px\]{min-height:320px}.w-\[13px\]{width:13px}.w-\[160px\]{width:160px}.w-\[16px\]{width:16px}.w-\[18px\]{width:18px}.w-\[22px\]{width:22px}.w-\[26px\]{width:26px}.w-\[34px\]{width:34px}.w-\[42\%\]{width:42%}.w-\[min\(380px\2c 92vw\)\]{width:min(380px,92vw)}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[60ch\]{max-width:60ch}.max-w-\[640px\]{max-width:640px}.max-w-\[70ch\]{max-width:70ch}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize-y{resize:vertical}.resize{resize:both}.scroll-mt-\[8px\]{scroll-margin-top:8px}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.grid-cols-\[130px_1fr\]{grid-template-columns:130px 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.items-start{align-items:flex-start}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-\[1px\]{gap:1px}.gap-\[2px\]{gap:2px}.gap-\[4px\]{gap:4px}.gap-\[5px\]{gap:5px}.gap-\[6px\]{gap:6px}.gap-\[9px\]{gap:9px}.gap-s1{gap:4.6px}.gap-s2{gap:9.2px}.gap-s3{gap:13.8px}.gap-s4{gap:18.4px}.gap-x-s3{-moz-column-gap:13.8px;column-gap:13.8px}.gap-y-\[2px\]{row-gap:2px}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-\[2px\]{border-radius:2px}.rounded-pill{border-radius:11px}.rounded-sm{border-radius:2px}.border{border-width:1px}.border-0{border-width:0}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l{border-left-width:1px}.border-l-2{border-left-width:2px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-accent{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.border-accent-deep{--tw-border-opacity:1;border-color:rgb(90 59 10/var(--tw-border-opacity,1))}.border-chrome-line{--tw-border-opacity:1;border-color:rgb(68 65 65/var(--tw-border-opacity,1))}.border-chrome-line-soft{--tw-border-opacity:1;border-color:rgb(45 43 43/var(--tw-border-opacity,1))}.border-chrome-muted{--tw-border-opacity:1;border-color:rgb(155 151 151/var(--tw-border-opacity,1))}.border-rule{border-color:rgba(32,31,29,.16)}.border-rule-strong{--tw-border-opacity:1;border-color:rgb(142 138 138/var(--tw-border-opacity,1))}.border-state-gap{--tw-border-opacity:1;border-color:rgb(91 74 156/var(--tw-border-opacity,1))}.border-state-warn{--tw-border-opacity:1;border-color:rgb(156 66 18/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.bg-\[rgba\(168\2c 113\2c 42\2c \.07\)\]{background-color:rgba(168,113,42,.07)}.bg-\[rgba\(32\2c 31\2c 29\2c \.03\)\]{background-color:rgba(32,31,29,.03)}.bg-black\/40{background-color:rgba(0,0,0,.4)}.bg-chrome{--tw-bg-opacity:1;background-color:rgb(27 26 25/var(--tw-bg-opacity,1))}.bg-chrome-alt{--tw-bg-opacity:1;background-color:rgb(32 31 29/var(--tw-bg-opacity,1))}.bg-chrome-surface{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.bg-paper{--tw-bg-opacity:1;background-color:rgb(243 242 242/var(--tw-bg-opacity,1))}.bg-paper-surface{--tw-bg-opacity:1;background-color:rgb(234 233 233/var(--tw-bg-opacity,1))}.bg-rule{background-color:rgba(32,31,29,.16)}.bg-transparent{background-color:transparent}.p-0{padding:0}.p-\[6px\]{padding:6px}.p-s2{padding:9.2px}.p-s3{padding:13.8px}.p-s4{padding:18.4px}.px-0{padding-left:0;padding-right:0}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-\[10px\]{padding-left:10px;padding-right:10px}.px-\[1px\]{padding-left:1px;padding-right:1px}.px-\[4px\]{padding-left:4px;padding-right:4px}.px-\[5px\]{padding-left:5px;padding-right:5px}.px-\[6px\]{padding-left:6px;padding-right:6px}.px-\[9px\]{padding-left:9px;padding-right:9px}.px-s2{padding-left:9.2px;padding-right:9.2px}.px-s3{padding-left:13.8px;padding-right:13.8px}.py-\[10px\]{padding-top:10px;padding-bottom:10px}.py-\[1px\]{padding-top:1px;padding-bottom:1px}.py-\[2px\]{padding-top:2px;padding-bottom:2px}.py-\[3px\]{padding-top:3px;padding-bottom:3px}.py-\[4px\]{padding-top:4px;padding-bottom:4px}.py-\[5px\]{padding-top:5px;padding-bottom:5px}.py-\[6px\]{padding-top:6px;padding-bottom:6px}.py-\[7px\]{padding-top:7px;padding-bottom:7px}.py-\[9px\]{padding-top:9px;padding-bottom:9px}.py-s2{padding-top:9.2px;padding-bottom:9.2px}.py-s3{padding-top:13.8px;padding-bottom:13.8px}.py-s4{padding-top:18.4px;padding-bottom:18.4px}.pb-\[1px\]{padding-bottom:1px}.pb-\[2px\]{padding-bottom:2px}.pb-\[4px\]{padding-bottom:4px}.pb-s1{padding-bottom:4.6px}.pb-s2{padding-bottom:9.2px}.pb-s3{padding-bottom:13.8px}.pl-0{padding-left:0}.pl-\[20px\]{padding-left:20px}.pl-\[22px\]{padding-left:22px}.pl-s2{padding-left:9.2px}.pl-s3{padding-left:13.8px}.pr-s3{padding-right:13.8px}.pt-\[9px\]{padding-top:9px}.pt-s1{padding-top:4.6px}.pt-s2{padding-top:9.2px}.pt-s3{padding-top:13.8px}.text-left{text-align:left}.text-right{text-align:right}.align-bottom{vertical-align:bottom}.align-\[-2px\]{vertical-align:-2px}.font-body{font-family:Lora,Georgia,serif}.font-diagram{font-family:ui-monospace,SF Mono,Menlo,monospace}.font-heading{font-family:Cormorant Garamond,Georgia,serif}.font-mono{font-family:ui-monospace,SF Mono,Menlo,monospace}.text-\[1\.1rem\]{font-size:1.1rem}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.text-\[17px\]{font-size:17px}.text-answer{font-size:.9375rem;line-height:1.55}.text-brand{font-size:1.1875rem;line-height:1.2}.text-caps{font-size:.75rem;line-height:1.3}.text-caveat{font-size:.9375rem;line-height:1.55}.text-chip{font-size:.8125rem;line-height:1.3}.text-intent{font-size:.875rem;line-height:1.2}.text-name{font-size:1.625rem;line-height:1.15}.text-provenance{font-size:.8125rem;line-height:1.45}.text-question{font-size:1.0625rem;line-height:1.25}.text-resource{font-size:.8125rem;line-height:1.35}.text-subtab{font-size:.875rem;line-height:1.2}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.leading-\[1\.95\]{line-height:1.95}.leading-none{line-height:1}.tracking-caps{letter-spacing:.09em}.tracking-normal{letter-spacing:0}.text-accent{--tw-text-opacity:1;color:rgb(182 130 53/var(--tw-text-opacity,1))}.text-accent-ink{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.text-accent-on-dark{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.text-chrome-ink{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.text-chrome-muted{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.text-ink{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.text-ink-muted{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.text-rule-strong{--tw-text-opacity:1;color:rgb(142 138 138/var(--tw-text-opacity,1))}.text-state-gap{--tw-text-opacity:1;color:rgb(91 74 156/var(--tw-text-opacity,1))}.text-state-gap-on-dark{--tw-text-opacity:1;color:rgb(169 154 224/var(--tw-text-opacity,1))}.text-state-ok{--tw-text-opacity:1;color:rgb(29 107 63/var(--tw-text-opacity,1))}.text-state-ok-on-dark{--tw-text-opacity:1;color:rgb(116 198 141/var(--tw-text-opacity,1))}.text-state-warn{--tw-text-opacity:1;color:rgb(156 66 18/var(--tw-text-opacity,1))}.text-state-warn-on-dark{--tw-text-opacity:1;color:rgb(242 162 106/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-dotted{text-decoration-style:dotted}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-100{opacity:1}.shadow-lg{--tw-shadow:0 12px 32px rgba(45,43,43,.22);--tw-shadow-colored:0 12px 32px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.placeholder\:text-chrome-muted::-moz-placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.placeholder\:text-chrome-muted::placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.placeholder\:text-ink-muted::-moz-placeholder{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.placeholder\:text-ink-muted::placeholder{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.last\:border-0:last-child{border-width:0}.hover\:border-accent:hover{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.hover\:bg-\[rgba\(32\2c 31\2c 29\2c \.05\)\]:hover{background-color:rgba(32,31,29,.05)}.hover\:bg-accent-tint:hover{--tw-bg-opacity:1;background-color:rgb(255 243 228/var(--tw-bg-opacity,1))}.hover\:bg-chrome-surface:hover{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.hover\:text-accent-ink:hover{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.hover\:text-accent-on-dark:hover{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.hover\:text-chrome-ink:hover{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.hover\:text-ink:hover{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.focus-visible\:text-chrome-ink:focus-visible{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))} \ No newline at end of file diff --git a/packages/resource-explorer/resource_explorer/web/static/re-api.js b/packages/resource-explorer/resource_explorer/web/static/re-api.js index 941480ee3..054ab19e1 100644 --- a/packages/resource-explorer/resource_explorer/web/static/re-api.js +++ b/packages/resource-explorer/resource_explorer/web/static/re-api.js @@ -50,6 +50,12 @@ const post = (path, body) => headers: JSON_HEADERS, body: body === undefined ? undefined : JSON.stringify(body), }); +const patch = (path, body) => + request(path, { + method: 'PATCH', + headers: JSON_HEADERS, + body: body === undefined ? undefined : JSON.stringify(body), + }); /* ──────────────────────────────────────────────────────────────────────── * A small TTL cache. @@ -187,6 +193,13 @@ export const setDisposition = (githubUrl, disposition, reason = '') => * read-modify-write rather than posting a single field: posting one answer * alone would silently blank environment, sensitivity and the rest. */ +/** Save ONE enrichment field. The server stamps author and date from the + * signed-in identity and does the read-modify-write, so two people setting + * two fields do not clobber each other. 401 when anonymous: a judgement + * needs an author. */ +export const saveEnrichmentField = (slug, key, { value = '', kind = 'judgement', source = '', evidence = {}, interim = false } = {}) => + patch(`/api/context/repo/${encodeURIComponent(slug)}/field`, { key, value, kind, source, evidence, interim }); + export const getContext = (entityType, slug) => get(`/api/context/${entityType}/${encodeURIComponent(slug)}`); diff --git a/packages/resource-explorer/tests/test_enrichment_fields.py b/packages/resource-explorer/tests/test_enrichment_fields.py new file mode 100644 index 000000000..39a915bb8 --- /dev/null +++ b/packages/resource-explorer/tests/test_enrichment_fields.py @@ -0,0 +1,105 @@ +"""Enrichment as testimony — PATCH /api/context/{type}/{slug}/field. + +A judgement carries an author, a date, and the evidence on screen when it was +made; an observation carries a source. The author is stamped by the server +from the signed-in identity, never taken from the client. Each field saves +alone. These drive the route with a faked identity, since the shape under +test is what the server stamps. +""" +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from resource_explorer.registry import Project, ProjectRegistry + + +@pytest.fixture +def registry(tmp_path): + r = ProjectRegistry(db_path=str(tmp_path / "t.db")) + r.add(Project(slug="p", display_name="P", github_url="https://github.com/x/p", description="")) + return r + + +@pytest.fixture +def client(registry, monkeypatch): + monkeypatch.setattr( + "resource_explorer.registry.ProjectRegistry.__init__", + lambda self, db_path=None: setattr(self, "__dict__", registry.__dict__) or None, + ) + monkeypatch.setenv("TRELLIS_ANONYMOUS_READ", "true") + from resource_explorer.web.app import app + return TestClient(app) + + +def signed_in_as(monkeypatch, user_id): + monkeypatch.setattr("resource_explorer.web.routes.context.get_current_user", lambda request: {"user_id": user_id}) + + +class TestJudgements: + def test_author_and_date_are_stamped_by_the_server(self, client, monkeypatch): + signed_in_as(monkeypatch, "peterprofile") + r = client.patch("/api/context/repo/p/field", json={ + "key": "sensitivity", "value": "internal", "kind": "judgement", + "evidence": {"cve_scan": "2026-09-03T00:00:00"}, + }) + assert r.status_code == 200, r.text + f = r.json()["field"] + assert f["author"] == "peterprofile" + assert f["set_at"].startswith("2026") + assert f["evidence"] == {"cve_scan": "2026-09-03T00:00:00"} + assert f["kind"] == "judgement" + + def test_the_client_cannot_assert_an_author(self, client, monkeypatch): + """A judgement with a client-supplied author is not testimony; the + write model does not even have the field.""" + signed_in_as(monkeypatch, "peterprofile") + r = client.patch("/api/context/repo/p/field", json={ + "key": "owner", "value": "someone", "kind": "judgement", "author": "forged", + }) + assert r.status_code == 200 + assert r.json()["field"]["author"] == "peterprofile" + + def test_anonymous_is_refused_not_recorded_as_nobody(self, client, monkeypatch): + monkeypatch.setattr("resource_explorer.web.routes.context.get_current_user", lambda request: None) + r = client.patch("/api/context/repo/p/field", json={"key": "sensitivity", "value": "internal"}) + assert r.status_code == 401 + + +class TestPerFieldSave: + def test_two_fields_saved_separately_both_survive(self, client, monkeypatch, registry): + """Eight independent facts should not share one Save. And a second + field's save must not clobber the first — the server does the + read-modify-write.""" + signed_in_as(monkeypatch, "a") + client.patch("/api/context/repo/p/field", json={"key": "sensitivity", "value": "internal"}) + signed_in_as(monkeypatch, "b") + client.patch("/api/context/repo/p/field", json={"key": "criticality", "value": "important"}) + ctx = registry.get_context("repo", "p") + e = ctx["enrichment"] + assert e["sensitivity"]["value"] == "internal" and e["sensitivity"]["author"] == "a" + assert e["criticality"]["value"] == "important" and e["criticality"]["author"] == "b" + + def test_shared_fields_are_mirrored_to_the_flat_keys_the_old_ui_reads(self, client, monkeypatch, registry): + signed_in_as(monkeypatch, "a") + client.patch("/api/context/repo/p/field", json={"key": "sensitivity", "value": "confidential"}) + client.patch("/api/context/repo/p/field", json={"key": "owner", "value": "data-platform"}) + ctx = registry.get_context("repo", "p") + assert ctx["sensitivity"] == "confidential" + assert ctx["org_owner"] == "data-platform" + + +class TestObservations: + def test_an_observation_carries_its_source(self, client, monkeypatch): + signed_in_as(monkeypatch, "a") + r = client.patch("/api/context/repo/p/field", json={ + "key": "licence", "value": "Apache License 2.0", "kind": "observation", "source": "license_classification", + }) + f = r.json()["field"] + assert f["kind"] == "observation" and f["source"] == "license_classification" + assert f["evidence"] == {} + + def test_unknown_kind_is_refused(self, client, monkeypatch): + signed_in_as(monkeypatch, "a") + r = client.patch("/api/context/repo/p/field", json={"key": "x", "value": "y", "kind": "opinion"}) + assert r.status_code == 422 From a8fb4146b15aa969460fb7eea0dc00e2a5c789cd Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Fri, 11 Sep 2026 17:01:05 -0500 Subject: [PATCH 08/37] The journal: why a resource matters, written to be read by someone else MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repo Handoff item 5 — small, standalone, and the only part of curation that was absent rather than imperfect. Everything else RE holds about a resource is written to be CORRECT: a measurement, a verdict, a field with an author. A journal entry is written to be READ — prose, dated, addressed to someone. Curation is not only getting the record right; it is advocacy, and a curator who catalogues a resource accurately and tells nobody has done half the job. Append-only. An entry is a statement someone made on a date; later events do not invalidate it, they get a later entry. So it sits outside the durable/perishable split entirely — no review flags, nothing to reconcile, never overwritten. The author is the signed-in user, stamped by the server; the payload has no author field to forge, and anonymous is refused rather than recorded as nobody's. Suggestion is a routing question and perspectives answer it: a note on a data-heavy repo is for Data Experts and Consumers, the same tags on every question row. So suggesting is "pick a perspective, or pick a person". And it arrives as a WORK-LIST ENTRY for them, not a notification — one list per audience, "Suggested to Data Expert", created on first use, the resource as a member with the entry as its rationale. Work lists already exist, carry counts, and survive being ignored for a fortnight; a notification is a thing you dismiss. A second suggestion of the same resource to the same audience updates the rationale rather than adding a row; the journal keeps every entry regardless. The UI says where the suggestion landed, not "sent". Lives on the Disposition sub-tab, now built: the verdict picker and its dated trail — "tracking · 32d ago" — above, the journal beneath, because the journal is the prose companion to the dated record. Nothing prompts for an entry at cataloguing time and nothing blocks on one: advocacy written to satisfy a required field is "useful library" on two hundred assets. The empty state is visible instead — "Nobody has written about this resource yet" — which is a fair thing for a corpus view to count. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- .../resource_explorer/journal.py | 149 ++++++++++++++++++ .../resource_explorer/web/app.py | 3 +- .../resource_explorer/web/routes/journal.py | 36 +++++ .../resource_explorer/web/static/next/app.js | 124 ++++++++++++++- .../web/static/next/tailwind-next.css | 2 +- .../resource_explorer/web/static/re-api.js | 8 + .../resource-explorer/tests/test_journal.py | 99 ++++++++++++ 7 files changed, 418 insertions(+), 3 deletions(-) create mode 100644 packages/resource-explorer/resource_explorer/journal.py create mode 100644 packages/resource-explorer/resource_explorer/web/routes/journal.py create mode 100644 packages/resource-explorer/tests/test_journal.py diff --git a/packages/resource-explorer/resource_explorer/journal.py b/packages/resource-explorer/resource_explorer/journal.py new file mode 100644 index 000000000..65a804051 --- /dev/null +++ b/packages/resource-explorer/resource_explorer/journal.py @@ -0,0 +1,149 @@ +"""The journal: why a resource matters, written to be read by someone else. + +Everything else RE holds about a resource is written to be CORRECT — a +measurement, a verdict, a field with an author. A journal entry is written +to be READ: prose, dated, addressed to someone. Curation is not only getting +the record right; it is advocacy, and a curator who catalogues a resource +accurately and tells nobody has done half the job. + +Three properties follow, and each is a relief: + +* **Append-only.** An entry is a statement someone made on a date. Later + events do not invalidate it — they get a later entry. So it sits outside + the durable/perishable split entirely: no review flags, nothing to + reconcile, never overwritten. +* **It has an audience, and the product already knows who.** Suggesting a + resource is a routing question, and perspectives answer it — a note on a + data-heavy repo is for Data Experts and Consumers, the same tags on every + question row. So suggestion is "pick a perspective, or pick a person", + not a share dialogue. +* **A suggestion arrives as a work-list entry, not a notification.** Work + lists already exist, carry counts, and survive being ignored for a + fortnight; a notification is a thing you dismiss. One list per audience — + "Suggested to Data Expert" — the resource as a member, the entry as its + rationale. The same promote mechanism the sub-resources and selections + already use. + +What this deliberately does not do: block cataloguing on an entry. Advocacy +written to satisfy a required field produces "useful library" on two +hundred assets. The empty state is visible instead — catalogued, never +written about — which a corpus view can count. + +The author is stamped by the caller from the signed-in identity; this +module never takes one on trust from a payload. +""" +from __future__ import annotations + +import json +import re +import uuid +from datetime import datetime, timezone + +from resource_explorer.registry import ProjectRegistry +from resource_explorer.work_lists import WorkLists + +SUGGESTION_PREFIX = "suggested-to-" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _ensure_schema(conn) -> None: + conn.execute(""" + CREATE TABLE IF NOT EXISTS resource_journal ( + id TEXT PRIMARY KEY, + entity_type TEXT NOT NULL, + entity_slug TEXT NOT NULL, + author TEXT NOT NULL, + written_at TEXT NOT NULL, + body TEXT NOT NULL, + suggested_to TEXT NOT NULL DEFAULT '[]' + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_resource_journal_entity " + "ON resource_journal(entity_type, entity_slug, written_at)") + + +def audience_slug(target: str) -> str: + """`Data Expert` -> `suggested-to-data-expert`; `peterprofile` -> + `suggested-to-peterprofile`. One work list per audience, found by slug.""" + return SUGGESTION_PREFIX + re.sub(r"[^a-z0-9]+", "-", target.strip().lower()).strip("-") + + +class Journal: + def __init__(self, registry: ProjectRegistry | None = None): + self.registry = registry or ProjectRegistry() + + def _conn(self): + return self.registry._conn() + + def entries(self, entity_type: str, entity_slug: str) -> list[dict]: + """Newest first. Every entry ever written; there is no delete.""" + with self._conn() as conn: + _ensure_schema(conn) + rows = conn.execute( + "SELECT id, author, written_at, body, suggested_to FROM resource_journal " + "WHERE entity_type = ? AND entity_slug = ? ORDER BY written_at DESC", + (entity_type, entity_slug), + ).fetchall() + out = [] + for r in rows: + d = dict(r) if not isinstance(r, dict) else r + try: + d["suggested_to"] = json.loads(d.get("suggested_to") or "[]") + except ValueError: + d["suggested_to"] = [] + out.append(d) + return out + + def write(self, entity_type: str, entity_slug: str, *, author: str, body: str, + suggest_to: list[str] | None = None) -> dict: + """Append one entry, and route it. Returns the entry plus the work + lists the suggestion landed in, so the UI can say where it went + rather than "sent".""" + if not author: + raise ValueError("a journal entry needs an author") + body = (body or "").strip() + if not body: + raise ValueError("a journal entry needs a body") + targets = [t.strip() for t in (suggest_to or []) if t and t.strip()] + entry_id = uuid.uuid4().hex + now = _now() + with self._conn() as conn: + _ensure_schema(conn) + conn.execute( + "INSERT INTO resource_journal (id, entity_type, entity_slug, author, written_at, body, suggested_to) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (entry_id, entity_type, entity_slug, author, now, body, json.dumps(targets)), + ) + landed = [self._route(target, entity_type, entity_slug, author, body) for target in targets] + return {"id": entry_id, "author": author, "written_at": now, "body": body, + "suggested_to": targets, "work_lists": landed} + + def _route(self, target: str, entity_type: str, entity_slug: str, author: str, body: str) -> dict: + """One work list per audience, created on first use; the resource + joins it with the entry as its rationale. A second suggestion of the + same resource to the same audience updates the rationale rather than + adding a second row — set_member upserts.""" + wl = WorkLists(self.registry) + slug = audience_slug(target) + existing = wl.get(slug) + if existing is None: + wl.create(f"Suggested to {target}", [entity_slug], slug=slug, entity_type=entity_type, + created_by=author, derived_from="journal", + description="Resources someone wrote about and suggested to this audience. " + "Each member's rationale is their journal entry.", + rationale=body) + else: + wl.set_member(slug, entity_slug, rationale=body) + return {"target": target, "work_list": slug} + + def suggested_targets(self, entity_type: str, entity_slug: str) -> list[str]: + """Everyone this resource has ever been suggested to.""" + seen: list[str] = [] + for e in self.entries(entity_type, entity_slug): + for t in e.get("suggested_to") or []: + if t not in seen: + seen.append(t) + return seen diff --git a/packages/resource-explorer/resource_explorer/web/app.py b/packages/resource-explorer/resource_explorer/web/app.py index 514f1c418..dde7b4e90 100644 --- a/packages/resource-explorer/resource_explorer/web/app.py +++ b/packages/resource-explorer/resource_explorer/web/app.py @@ -12,7 +12,7 @@ from fastapi.staticfiles import StaticFiles from resource_explorer.web.routes import work_lists as work_lists_routes -from resource_explorer.web.routes import activity, aliases, auth as auth_routes, compile_context as compile_context_routes, analyses, automate, bootstrap as bootstrap_routes, context, curate, databases, db_servers as db_servers_routes, diagrams, discovery, egeria, feedback, investigations, logs as logs_routes, prefect_status, project_context, outbox, projects, query, repair, runs as runs_routes, schedules, stats, webhook, filesystems, survey_definitions +from resource_explorer.web.routes import activity, aliases, auth as auth_routes, compile_context as compile_context_routes, analyses, automate, bootstrap as bootstrap_routes, context, curate, databases, db_servers as db_servers_routes, diagrams, discovery, egeria, feedback, investigations, journal, logs as logs_routes, prefect_status, project_context, outbox, projects, query, repair, runs as runs_routes, schedules, stats, webhook, filesystems, survey_definitions log = logging.getLogger(__name__) @@ -196,6 +196,7 @@ def _install_login_required_middleware() -> None: app.include_router(survey_definitions.router, prefix="/api/survey-definitions", tags=["survey-definitions"]) app.include_router(outbox.router, prefix="/api/outbox", tags=["outbox"]) app.include_router(feedback.router, prefix="/api/feedback", tags=["feedback"]) +app.include_router(journal.router, prefix="/api/journal", tags=["journal"]) app.include_router(discovery.router, prefix="/api/discovery", tags=["discovery"]) app.include_router(diagrams.router, prefix="/api/diagrams", tags=["diagrams"]) app.include_router(investigations.router, prefix="/api/investigations", tags=["investigations"]) diff --git a/packages/resource-explorer/resource_explorer/web/routes/journal.py b/packages/resource-explorer/resource_explorer/web/routes/journal.py new file mode 100644 index 000000000..3e605a063 --- /dev/null +++ b/packages/resource-explorer/resource_explorer/web/routes/journal.py @@ -0,0 +1,36 @@ +"""The journal — resource_explorer/journal.py, over HTTP.""" +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +from resource_explorer.auth import get_current_user +from resource_explorer.journal import Journal + +router = APIRouter() + + +class JournalWrite(BaseModel): + body: str + suggest_to: list[str] = Field(default_factory=list) + + +@router.get("/{entity_type}/{slug}") +def list_entries(entity_type: str, slug: str) -> dict: + j = Journal() + return {"entries": j.entries(entity_type, slug), "suggested_to": j.suggested_targets(entity_type, slug)} + + +@router.post("/{entity_type}/{slug}") +def write_entry(entity_type: str, slug: str, write: JournalWrite, request: Request) -> dict: + """Append one entry. The author is the signed-in user, stamped here; + anonymous is refused rather than recorded as nobody's, and the payload + carries no author field to forge.""" + user = get_current_user(request) + author = (user or {}).get("user_id") or (user or {}).get("sub") or (user or {}).get("username") or "" + if not author: + raise HTTPException(status_code=401, detail="Sign in to write in the journal — an entry needs an author.") + try: + return Journal().write(entity_type, slug, author=author, body=write.body, suggest_to=write.suggest_to) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) diff --git a/packages/resource-explorer/resource_explorer/web/static/next/app.js b/packages/resource-explorer/resource_explorer/web/static/next/app.js index 484bde595..2f3e61f8f 100644 --- a/packages/resource-explorer/resource_explorer/web/static/next/app.js +++ b/packages/resource-explorer/resource_explorer/web/static/next/app.js @@ -65,7 +65,9 @@ import { setDisposition, setWorkingSetHidden, getContext, + getJournal, questionKey, + writeJournal, saveEnrichmentField, saveQuestionAnswer, } from '/static/re-api.js'; @@ -147,7 +149,7 @@ const SUB_TABS = [ { id: 'survey', label: 'Survey', does: 'Survey definitions, their steps, and running them', built: true }, { id: 'dashboard', label: 'Dashboard', does: 'Survey results — health, maturity, community, charts', built: true }, { id: 'questions', label: 'Questions', does: 'The question checklist' }, - { id: 'disposition', label: 'Disposition', does: 'Set a verdict on this resource, and its history' }, + { id: 'disposition', label: 'Disposition', does: 'Set a verdict on this resource, its history, and the journal', built: true }, ]; /* ════════════════════════════════════════════════════════════════════════ @@ -2678,6 +2680,125 @@ function bindSubTabs() { * ════════════════════════════════════════════════════════════════════════ */ /** A resource must be selected and be a repo for either pane to mean anything. */ +/* ── Disposition and the journal ───────────────────────────────────────── + * + * The verdict and its dated trail were already in the resource header. What + * was missing is the journal: WHY a resource matters, written to be read by + * someone else. Everything else on these screens is written to be correct; + * this is the first thing written to be read. So the affordance shows it — + * room to write, no dropdown, a name on it. + * + * Append-only. An entry is a statement someone made on a date; later events + * do not invalidate it, they get a later entry. Outside the durable / + * perishable split entirely: no review flags, nothing to reconcile. + * + * Suggestion is a routing question and perspectives answer it — a note on a + * data-heavy repo is for Data Experts and Consumers, the same tags on every + * question row. It arrives as a WORK-LIST ENTRY for them, not a + * notification: work lists already exist, carry counts, and survive being + * ignored for a fortnight; a notification is a thing you dismiss. The UI + * says where it landed rather than "sent". + * + * Nothing prompts for an entry at cataloguing time, and nothing blocks on + * one. Advocacy written to satisfy a required field is "useful library" on + * two hundred assets. The empty state is visible instead. + */ +async function loadDispositionPane() { + const el = $('content'); + const blocked = paneNeedsRepo(); + if (blocked) { el.innerHTML = subTabsHtml() + blocked; bindSubTabs(); return; } + const slug = state.selectedSlug; + el.innerHTML = `${subTabsHtml()} +
    ${resourceHeaderHtml(slug)}
    +
    +
    Verdicts
    +
    Loading history…
    +
    + Journal + why it matters, and to whom · written to be read +
    +
    +
    Reading the journal…
    `; + bindSubTabs(); + bindResourceHeader(); + const project = state.projects.find((x) => x.slug === slug); + if (project?.github_url) renderDispositionHistory(project.github_url); + else $('disposition-history').textContent = 'No GitHub URL, so no disposition can be keyed to this resource.'; + renderJournalWrite(slug); + await renderJournalEntries(slug); +} + +function renderJournalWrite(slug) { + const host = $('journal-write'); + if (!host) return; + const who = (state.me && (state.me.user_id || state.me.username || state.me.egeria_user)) || ''; + const perspectives = (state.perspectives || []).map((p) => p.name || p.id || p).filter(Boolean); + host.innerHTML = ` + +
    + suggest to + ${perspectives.map((p) => ``).join('')} + +
    +
    + + ${who ? `as ${esc(who)}` : 'sign in to write — an entry needs an author'} + · a suggestion is a work-list entry for them, not a notification +
    `; + $('journal-save').addEventListener('click', async () => { + const body = $('journal-body').value.trim(); + if (!body) { $('journal-body').focus(); return; } + const targets = [...host.querySelectorAll('[data-suggest]:checked')].map((c) => c.dataset.suggest); + const person = ($('journal-person').value || '').trim(); + if (person) targets.push(person); + const b = $('journal-save'); b.disabled = true; b.textContent = 'writing…'; + try { + const out = await writeJournal(slug, body, targets); + $('journal-body').value = ''; $('journal-person').value = ''; + host.querySelectorAll('[data-suggest]').forEach((c) => { c.checked = false; }); + b.disabled = false; b.textContent = 'Write'; + const where = (out.work_lists || []).map((w) => w.work_list).join(', '); + const note = document.createElement('div'); + note.className = 'mt-s1 text-provenance text-ink-muted'; + note.textContent = where ? `written · suggested — now in ${where}` : 'written'; + host.appendChild(note); + setTimeout(() => note.remove(), 6000); + await renderJournalEntries(slug); + } catch (err) { + b.disabled = false; + b.textContent = err.status === 401 ? 'sign in to write' : `not written: ${err.message}`; + } + }); +} + +async function renderJournalEntries(slug) { + const host = $('journal-entries'); + if (!host) return; + let data; + try { data = await getJournal(slug); } + catch (err) { host.innerHTML = `The journal could not be read: ${esc(err.message)}`; return; } + if (slug !== state.selectedSlug) return; + const entries = data.entries || []; + if (!entries.length) { + // Visible, and a fair thing for a corpus view to count: catalogued, + // never written about. + host.innerHTML = `Nobody has written about this resource yet.`; + return; + } + host.innerHTML = ` +
    Earlier entries · ${entries.length}${ + data.suggested_to?.length ? ` · suggested to ${esc(data.suggested_to.join(', '))}` : ''}
    + ${entries.map((e) => `
    +

    ${tnum(esc(e.body))}

    +
    ${esc(e.author)} · ${esc(ago(e.written_at))}${ + e.suggested_to?.length ? ` · suggested to ${esc(e.suggested_to.join(', '))}` : ''}
    +
    `).join('')}`; +} + function paneNeedsRepo() { if (state.resourceType !== 'repo') { return paneMessage('Repos only, in /next', @@ -4369,6 +4490,7 @@ async function loadPane() { if (state.subTab === 'survey') { await loadSurveyPane(); return; } if (state.subTab === 'dashboard') { await loadDashboardPane(); return; } + if (state.subTab === 'disposition') { await loadDispositionPane(); return; } if (state.subTab !== 'questions') { const tab = SUB_TABS.find((t) => t.id === state.subTab) diff --git a/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css b/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css index f571b5f7f..5cb2f7058 100644 --- a/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css +++ b/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SF Mono,Menlo,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.z-40{z-index:40}.z-50{z-index:50}.z-\[90\]{z-index:90}.m-0{margin:0}.my-s3{margin-top:13.8px;margin-bottom:13.8px}.-ml-\[22px\]{margin-left:-22px}.mb-\[2px\]{margin-bottom:2px}.mb-\[3px\]{margin-bottom:3px}.mb-\[4px\]{margin-bottom:4px}.mb-\[6px\]{margin-bottom:6px}.mb-\[7px\]{margin-bottom:7px}.mb-s1{margin-bottom:4.6px}.mb-s2{margin-bottom:9.2px}.mb-s3{margin-bottom:13.8px}.mb-s4{margin-bottom:18.4px}.ml-\[22px\]{margin-left:22px}.ml-auto{margin-left:auto}.ml-s2{margin-left:9.2px}.ml-s3{margin-left:13.8px}.mr-1{margin-right:.25rem}.mr-s3{margin-right:13.8px}.mt-\[10px\]{margin-top:10px}.mt-\[2px\]{margin-top:2px}.mt-\[3px\]{margin-top:3px}.mt-\[4px\]{margin-top:4px}.mt-\[5px\]{margin-top:5px}.mt-\[6px\]{margin-top:6px}.mt-\[6vh\]{margin-top:6vh}.mt-\[7px\]{margin-top:7px}.mt-s1{margin-top:4.6px}.mt-s2{margin-top:9.2px}.mt-s3{margin-top:13.8px}.mt-s4{margin-top:18.4px}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.\!grid{display:grid!important}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-\[14px\]{height:14px}.h-\[9px\]{height:9px}.h-full{height:100%}.h-px{height:1px}.min-h-0{min-height:0}.min-h-\[320px\]{min-height:320px}.w-\[13px\]{width:13px}.w-\[160px\]{width:160px}.w-\[16px\]{width:16px}.w-\[18px\]{width:18px}.w-\[22px\]{width:22px}.w-\[26px\]{width:26px}.w-\[34px\]{width:34px}.w-\[42\%\]{width:42%}.w-\[min\(380px\2c 92vw\)\]{width:min(380px,92vw)}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[60ch\]{max-width:60ch}.max-w-\[640px\]{max-width:640px}.max-w-\[70ch\]{max-width:70ch}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize-y{resize:vertical}.resize{resize:both}.scroll-mt-\[8px\]{scroll-margin-top:8px}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.grid-cols-\[130px_1fr\]{grid-template-columns:130px 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.items-start{align-items:flex-start}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-\[1px\]{gap:1px}.gap-\[2px\]{gap:2px}.gap-\[4px\]{gap:4px}.gap-\[5px\]{gap:5px}.gap-\[6px\]{gap:6px}.gap-\[9px\]{gap:9px}.gap-s1{gap:4.6px}.gap-s2{gap:9.2px}.gap-s3{gap:13.8px}.gap-s4{gap:18.4px}.gap-x-s3{-moz-column-gap:13.8px;column-gap:13.8px}.gap-y-\[2px\]{row-gap:2px}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-\[2px\]{border-radius:2px}.rounded-pill{border-radius:11px}.rounded-sm{border-radius:2px}.border{border-width:1px}.border-0{border-width:0}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l{border-left-width:1px}.border-l-2{border-left-width:2px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-accent{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.border-accent-deep{--tw-border-opacity:1;border-color:rgb(90 59 10/var(--tw-border-opacity,1))}.border-chrome-line{--tw-border-opacity:1;border-color:rgb(68 65 65/var(--tw-border-opacity,1))}.border-chrome-line-soft{--tw-border-opacity:1;border-color:rgb(45 43 43/var(--tw-border-opacity,1))}.border-chrome-muted{--tw-border-opacity:1;border-color:rgb(155 151 151/var(--tw-border-opacity,1))}.border-rule{border-color:rgba(32,31,29,.16)}.border-rule-strong{--tw-border-opacity:1;border-color:rgb(142 138 138/var(--tw-border-opacity,1))}.border-state-gap{--tw-border-opacity:1;border-color:rgb(91 74 156/var(--tw-border-opacity,1))}.border-state-warn{--tw-border-opacity:1;border-color:rgb(156 66 18/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.bg-\[rgba\(168\2c 113\2c 42\2c \.07\)\]{background-color:rgba(168,113,42,.07)}.bg-\[rgba\(32\2c 31\2c 29\2c \.03\)\]{background-color:rgba(32,31,29,.03)}.bg-black\/40{background-color:rgba(0,0,0,.4)}.bg-chrome{--tw-bg-opacity:1;background-color:rgb(27 26 25/var(--tw-bg-opacity,1))}.bg-chrome-alt{--tw-bg-opacity:1;background-color:rgb(32 31 29/var(--tw-bg-opacity,1))}.bg-chrome-surface{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.bg-paper{--tw-bg-opacity:1;background-color:rgb(243 242 242/var(--tw-bg-opacity,1))}.bg-paper-surface{--tw-bg-opacity:1;background-color:rgb(234 233 233/var(--tw-bg-opacity,1))}.bg-rule{background-color:rgba(32,31,29,.16)}.bg-transparent{background-color:transparent}.p-0{padding:0}.p-\[6px\]{padding:6px}.p-s2{padding:9.2px}.p-s3{padding:13.8px}.p-s4{padding:18.4px}.px-0{padding-left:0;padding-right:0}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-\[10px\]{padding-left:10px;padding-right:10px}.px-\[1px\]{padding-left:1px;padding-right:1px}.px-\[4px\]{padding-left:4px;padding-right:4px}.px-\[5px\]{padding-left:5px;padding-right:5px}.px-\[6px\]{padding-left:6px;padding-right:6px}.px-\[9px\]{padding-left:9px;padding-right:9px}.px-s2{padding-left:9.2px;padding-right:9.2px}.px-s3{padding-left:13.8px;padding-right:13.8px}.py-\[10px\]{padding-top:10px;padding-bottom:10px}.py-\[1px\]{padding-top:1px;padding-bottom:1px}.py-\[2px\]{padding-top:2px;padding-bottom:2px}.py-\[3px\]{padding-top:3px;padding-bottom:3px}.py-\[4px\]{padding-top:4px;padding-bottom:4px}.py-\[5px\]{padding-top:5px;padding-bottom:5px}.py-\[6px\]{padding-top:6px;padding-bottom:6px}.py-\[7px\]{padding-top:7px;padding-bottom:7px}.py-\[9px\]{padding-top:9px;padding-bottom:9px}.py-s2{padding-top:9.2px;padding-bottom:9.2px}.py-s3{padding-top:13.8px;padding-bottom:13.8px}.py-s4{padding-top:18.4px;padding-bottom:18.4px}.pb-\[1px\]{padding-bottom:1px}.pb-\[2px\]{padding-bottom:2px}.pb-\[4px\]{padding-bottom:4px}.pb-s1{padding-bottom:4.6px}.pb-s2{padding-bottom:9.2px}.pb-s3{padding-bottom:13.8px}.pl-0{padding-left:0}.pl-\[20px\]{padding-left:20px}.pl-\[22px\]{padding-left:22px}.pl-s2{padding-left:9.2px}.pl-s3{padding-left:13.8px}.pr-s3{padding-right:13.8px}.pt-\[9px\]{padding-top:9px}.pt-s1{padding-top:4.6px}.pt-s2{padding-top:9.2px}.pt-s3{padding-top:13.8px}.text-left{text-align:left}.text-right{text-align:right}.align-bottom{vertical-align:bottom}.align-\[-2px\]{vertical-align:-2px}.font-body{font-family:Lora,Georgia,serif}.font-diagram{font-family:ui-monospace,SF Mono,Menlo,monospace}.font-heading{font-family:Cormorant Garamond,Georgia,serif}.font-mono{font-family:ui-monospace,SF Mono,Menlo,monospace}.text-\[1\.1rem\]{font-size:1.1rem}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.text-\[17px\]{font-size:17px}.text-answer{font-size:.9375rem;line-height:1.55}.text-brand{font-size:1.1875rem;line-height:1.2}.text-caps{font-size:.75rem;line-height:1.3}.text-caveat{font-size:.9375rem;line-height:1.55}.text-chip{font-size:.8125rem;line-height:1.3}.text-intent{font-size:.875rem;line-height:1.2}.text-name{font-size:1.625rem;line-height:1.15}.text-provenance{font-size:.8125rem;line-height:1.45}.text-question{font-size:1.0625rem;line-height:1.25}.text-resource{font-size:.8125rem;line-height:1.35}.text-subtab{font-size:.875rem;line-height:1.2}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.leading-\[1\.95\]{line-height:1.95}.leading-none{line-height:1}.tracking-caps{letter-spacing:.09em}.tracking-normal{letter-spacing:0}.text-accent{--tw-text-opacity:1;color:rgb(182 130 53/var(--tw-text-opacity,1))}.text-accent-ink{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.text-accent-on-dark{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.text-chrome-ink{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.text-chrome-muted{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.text-ink{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.text-ink-muted{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.text-rule-strong{--tw-text-opacity:1;color:rgb(142 138 138/var(--tw-text-opacity,1))}.text-state-gap{--tw-text-opacity:1;color:rgb(91 74 156/var(--tw-text-opacity,1))}.text-state-gap-on-dark{--tw-text-opacity:1;color:rgb(169 154 224/var(--tw-text-opacity,1))}.text-state-ok{--tw-text-opacity:1;color:rgb(29 107 63/var(--tw-text-opacity,1))}.text-state-ok-on-dark{--tw-text-opacity:1;color:rgb(116 198 141/var(--tw-text-opacity,1))}.text-state-warn{--tw-text-opacity:1;color:rgb(156 66 18/var(--tw-text-opacity,1))}.text-state-warn-on-dark{--tw-text-opacity:1;color:rgb(242 162 106/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-dotted{text-decoration-style:dotted}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-100{opacity:1}.shadow-lg{--tw-shadow:0 12px 32px rgba(45,43,43,.22);--tw-shadow-colored:0 12px 32px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.placeholder\:text-chrome-muted::-moz-placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.placeholder\:text-chrome-muted::placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.placeholder\:text-ink-muted::-moz-placeholder{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.placeholder\:text-ink-muted::placeholder{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.last\:border-0:last-child{border-width:0}.hover\:border-accent:hover{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.hover\:bg-\[rgba\(32\2c 31\2c 29\2c \.05\)\]:hover{background-color:rgba(32,31,29,.05)}.hover\:bg-accent-tint:hover{--tw-bg-opacity:1;background-color:rgb(255 243 228/var(--tw-bg-opacity,1))}.hover\:bg-chrome-surface:hover{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.hover\:text-accent-ink:hover{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.hover\:text-accent-on-dark:hover{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.hover\:text-chrome-ink:hover{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.hover\:text-ink:hover{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.focus-visible\:text-chrome-ink:focus-visible{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SF Mono,Menlo,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.z-40{z-index:40}.z-50{z-index:50}.z-\[90\]{z-index:90}.m-0{margin:0}.my-s3{margin-top:13.8px;margin-bottom:13.8px}.-ml-\[22px\]{margin-left:-22px}.mb-\[2px\]{margin-bottom:2px}.mb-\[3px\]{margin-bottom:3px}.mb-\[4px\]{margin-bottom:4px}.mb-\[6px\]{margin-bottom:6px}.mb-\[7px\]{margin-bottom:7px}.mb-s1{margin-bottom:4.6px}.mb-s2{margin-bottom:9.2px}.mb-s3{margin-bottom:13.8px}.mb-s4{margin-bottom:18.4px}.ml-\[22px\]{margin-left:22px}.ml-auto{margin-left:auto}.ml-s2{margin-left:9.2px}.ml-s3{margin-left:13.8px}.mr-1{margin-right:.25rem}.mr-s3{margin-right:13.8px}.mt-\[10px\]{margin-top:10px}.mt-\[2px\]{margin-top:2px}.mt-\[3px\]{margin-top:3px}.mt-\[4px\]{margin-top:4px}.mt-\[5px\]{margin-top:5px}.mt-\[6px\]{margin-top:6px}.mt-\[6vh\]{margin-top:6vh}.mt-\[7px\]{margin-top:7px}.mt-s1{margin-top:4.6px}.mt-s2{margin-top:9.2px}.mt-s3{margin-top:13.8px}.mt-s4{margin-top:18.4px}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.\!grid{display:grid!important}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-\[14px\]{height:14px}.h-\[9px\]{height:9px}.h-full{height:100%}.h-px{height:1px}.min-h-0{min-height:0}.min-h-\[320px\]{min-height:320px}.w-\[12ch\]{width:12ch}.w-\[13px\]{width:13px}.w-\[160px\]{width:160px}.w-\[16px\]{width:16px}.w-\[18px\]{width:18px}.w-\[22px\]{width:22px}.w-\[26px\]{width:26px}.w-\[34px\]{width:34px}.w-\[42\%\]{width:42%}.w-\[min\(380px\2c 92vw\)\]{width:min(380px,92vw)}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[60ch\]{max-width:60ch}.max-w-\[640px\]{max-width:640px}.max-w-\[70ch\]{max-width:70ch}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize-y{resize:vertical}.resize{resize:both}.scroll-mt-\[8px\]{scroll-margin-top:8px}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.grid-cols-\[130px_1fr\]{grid-template-columns:130px 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.items-start{align-items:flex-start}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-\[1px\]{gap:1px}.gap-\[2px\]{gap:2px}.gap-\[4px\]{gap:4px}.gap-\[5px\]{gap:5px}.gap-\[6px\]{gap:6px}.gap-\[9px\]{gap:9px}.gap-s1{gap:4.6px}.gap-s2{gap:9.2px}.gap-s3{gap:13.8px}.gap-s4{gap:18.4px}.gap-x-s3{-moz-column-gap:13.8px;column-gap:13.8px}.gap-y-\[2px\]{row-gap:2px}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-\[2px\]{border-radius:2px}.rounded-pill{border-radius:11px}.rounded-sm{border-radius:2px}.border{border-width:1px}.border-0{border-width:0}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l{border-left-width:1px}.border-l-2{border-left-width:2px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-accent{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.border-accent-deep{--tw-border-opacity:1;border-color:rgb(90 59 10/var(--tw-border-opacity,1))}.border-chrome-line{--tw-border-opacity:1;border-color:rgb(68 65 65/var(--tw-border-opacity,1))}.border-chrome-line-soft{--tw-border-opacity:1;border-color:rgb(45 43 43/var(--tw-border-opacity,1))}.border-chrome-muted{--tw-border-opacity:1;border-color:rgb(155 151 151/var(--tw-border-opacity,1))}.border-rule{border-color:rgba(32,31,29,.16)}.border-rule-strong{--tw-border-opacity:1;border-color:rgb(142 138 138/var(--tw-border-opacity,1))}.border-state-gap{--tw-border-opacity:1;border-color:rgb(91 74 156/var(--tw-border-opacity,1))}.border-state-warn{--tw-border-opacity:1;border-color:rgb(156 66 18/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.bg-\[rgba\(168\2c 113\2c 42\2c \.07\)\]{background-color:rgba(168,113,42,.07)}.bg-\[rgba\(32\2c 31\2c 29\2c \.03\)\]{background-color:rgba(32,31,29,.03)}.bg-black\/40{background-color:rgba(0,0,0,.4)}.bg-chrome{--tw-bg-opacity:1;background-color:rgb(27 26 25/var(--tw-bg-opacity,1))}.bg-chrome-alt{--tw-bg-opacity:1;background-color:rgb(32 31 29/var(--tw-bg-opacity,1))}.bg-chrome-surface{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.bg-paper{--tw-bg-opacity:1;background-color:rgb(243 242 242/var(--tw-bg-opacity,1))}.bg-paper-surface{--tw-bg-opacity:1;background-color:rgb(234 233 233/var(--tw-bg-opacity,1))}.bg-rule{background-color:rgba(32,31,29,.16)}.bg-transparent{background-color:transparent}.p-0{padding:0}.p-\[6px\]{padding:6px}.p-s2{padding:9.2px}.p-s3{padding:13.8px}.p-s4{padding:18.4px}.px-0{padding-left:0;padding-right:0}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-\[10px\]{padding-left:10px;padding-right:10px}.px-\[1px\]{padding-left:1px;padding-right:1px}.px-\[4px\]{padding-left:4px;padding-right:4px}.px-\[5px\]{padding-left:5px;padding-right:5px}.px-\[6px\]{padding-left:6px;padding-right:6px}.px-\[9px\]{padding-left:9px;padding-right:9px}.px-s2{padding-left:9.2px;padding-right:9.2px}.px-s3{padding-left:13.8px;padding-right:13.8px}.py-\[10px\]{padding-top:10px;padding-bottom:10px}.py-\[1px\]{padding-top:1px;padding-bottom:1px}.py-\[2px\]{padding-top:2px;padding-bottom:2px}.py-\[3px\]{padding-top:3px;padding-bottom:3px}.py-\[4px\]{padding-top:4px;padding-bottom:4px}.py-\[5px\]{padding-top:5px;padding-bottom:5px}.py-\[6px\]{padding-top:6px;padding-bottom:6px}.py-\[7px\]{padding-top:7px;padding-bottom:7px}.py-\[9px\]{padding-top:9px;padding-bottom:9px}.py-s2{padding-top:9.2px;padding-bottom:9.2px}.py-s3{padding-top:13.8px;padding-bottom:13.8px}.py-s4{padding-top:18.4px;padding-bottom:18.4px}.pb-\[1px\]{padding-bottom:1px}.pb-\[2px\]{padding-bottom:2px}.pb-\[4px\]{padding-bottom:4px}.pb-s1{padding-bottom:4.6px}.pb-s2{padding-bottom:9.2px}.pb-s3{padding-bottom:13.8px}.pl-0{padding-left:0}.pl-\[20px\]{padding-left:20px}.pl-\[22px\]{padding-left:22px}.pl-s2{padding-left:9.2px}.pl-s3{padding-left:13.8px}.pr-s3{padding-right:13.8px}.pt-\[9px\]{padding-top:9px}.pt-s1{padding-top:4.6px}.pt-s2{padding-top:9.2px}.pt-s3{padding-top:13.8px}.text-left{text-align:left}.text-right{text-align:right}.align-bottom{vertical-align:bottom}.align-\[-2px\]{vertical-align:-2px}.font-body{font-family:Lora,Georgia,serif}.font-diagram{font-family:ui-monospace,SF Mono,Menlo,monospace}.font-heading{font-family:Cormorant Garamond,Georgia,serif}.font-mono{font-family:ui-monospace,SF Mono,Menlo,monospace}.text-\[1\.1rem\]{font-size:1.1rem}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.text-\[17px\]{font-size:17px}.text-answer{font-size:.9375rem;line-height:1.55}.text-brand{font-size:1.1875rem;line-height:1.2}.text-caps{font-size:.75rem;line-height:1.3}.text-caveat{font-size:.9375rem;line-height:1.55}.text-chip{font-size:.8125rem;line-height:1.3}.text-intent{font-size:.875rem;line-height:1.2}.text-name{font-size:1.625rem;line-height:1.15}.text-provenance{font-size:.8125rem;line-height:1.45}.text-question{font-size:1.0625rem;line-height:1.25}.text-resource{font-size:.8125rem;line-height:1.35}.text-subtab{font-size:.875rem;line-height:1.2}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.leading-\[1\.95\]{line-height:1.95}.leading-none{line-height:1}.tracking-caps{letter-spacing:.09em}.tracking-normal{letter-spacing:0}.text-accent{--tw-text-opacity:1;color:rgb(182 130 53/var(--tw-text-opacity,1))}.text-accent-ink{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.text-accent-on-dark{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.text-chrome-ink{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.text-chrome-muted{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.text-ink{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.text-ink-muted{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.text-rule-strong{--tw-text-opacity:1;color:rgb(142 138 138/var(--tw-text-opacity,1))}.text-state-gap{--tw-text-opacity:1;color:rgb(91 74 156/var(--tw-text-opacity,1))}.text-state-gap-on-dark{--tw-text-opacity:1;color:rgb(169 154 224/var(--tw-text-opacity,1))}.text-state-ok{--tw-text-opacity:1;color:rgb(29 107 63/var(--tw-text-opacity,1))}.text-state-ok-on-dark{--tw-text-opacity:1;color:rgb(116 198 141/var(--tw-text-opacity,1))}.text-state-warn{--tw-text-opacity:1;color:rgb(156 66 18/var(--tw-text-opacity,1))}.text-state-warn-on-dark{--tw-text-opacity:1;color:rgb(242 162 106/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-dotted{text-decoration-style:dotted}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-100{opacity:1}.shadow-lg{--tw-shadow:0 12px 32px rgba(45,43,43,.22);--tw-shadow-colored:0 12px 32px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.placeholder\:text-chrome-muted::-moz-placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.placeholder\:text-chrome-muted::placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.placeholder\:text-ink-muted::-moz-placeholder{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.placeholder\:text-ink-muted::placeholder{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.last\:border-0:last-child{border-width:0}.hover\:border-accent:hover{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.hover\:bg-\[rgba\(32\2c 31\2c 29\2c \.05\)\]:hover{background-color:rgba(32,31,29,.05)}.hover\:bg-accent-tint:hover{--tw-bg-opacity:1;background-color:rgb(255 243 228/var(--tw-bg-opacity,1))}.hover\:bg-chrome-surface:hover{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.hover\:text-accent-ink:hover{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.hover\:text-accent-on-dark:hover{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.hover\:text-chrome-ink:hover{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.hover\:text-ink:hover{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.focus-visible\:text-chrome-ink:focus-visible{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))} \ No newline at end of file diff --git a/packages/resource-explorer/resource_explorer/web/static/re-api.js b/packages/resource-explorer/resource_explorer/web/static/re-api.js index 054ab19e1..5fe0cc0ac 100644 --- a/packages/resource-explorer/resource_explorer/web/static/re-api.js +++ b/packages/resource-explorer/resource_explorer/web/static/re-api.js @@ -200,6 +200,14 @@ export const setDisposition = (githubUrl, disposition, reason = '') => export const saveEnrichmentField = (slug, key, { value = '', kind = 'judgement', source = '', evidence = {}, interim = false } = {}) => patch(`/api/context/repo/${encodeURIComponent(slug)}/field`, { key, value, kind, source, evidence, interim }); +/* ── The journal ────────────────────────────────────────────────────────── + * Append-only prose on a resource, with a server-stamped author. A + * suggestion is routed by perspective or person and arrives as a work-list + * entry for them — never a notification. */ +export const getJournal = (slug) => get(`/api/journal/repo/${encodeURIComponent(slug)}`); +export const writeJournal = (slug, body, suggestTo = []) => + post(`/api/journal/repo/${encodeURIComponent(slug)}`, { body, suggest_to: suggestTo }); + export const getContext = (entityType, slug) => get(`/api/context/${entityType}/${encodeURIComponent(slug)}`); diff --git a/packages/resource-explorer/tests/test_journal.py b/packages/resource-explorer/tests/test_journal.py new file mode 100644 index 000000000..4fe1565f2 --- /dev/null +++ b/packages/resource-explorer/tests/test_journal.py @@ -0,0 +1,99 @@ +"""The journal — resource_explorer/journal.py. + +Append-only prose with an author and a date; suggestion routes to a +work list per audience, never a notification. These drive the module on a +fresh registry and the route with a faked identity. +""" +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from resource_explorer.journal import Journal, audience_slug +from resource_explorer.registry import Project, ProjectRegistry +from resource_explorer.work_lists import WorkLists + + +@pytest.fixture +def registry(tmp_path): + r = ProjectRegistry(db_path=str(tmp_path / "t.db")) + for s in ("p", "q"): + r.add(Project(slug=s, display_name=s.upper(), github_url=f"https://github.com/x/{s}", description="")) + return r + + +class TestAppendOnly: + def test_entries_accumulate_newest_first_and_nothing_is_overwritten(self, registry): + j = Journal(registry) + j.write("repo", "p", author="a", body="Picked up during scouting.") + j.write("repo", "p", author="a", body="Dependency count moved 57 → 68.") + entries = j.entries("repo", "p") + assert [e["body"] for e in entries] == ["Dependency count moved 57 → 68.", "Picked up during scouting."] + assert all(e["author"] == "a" and e["written_at"].startswith("2026") for e in entries) + + def test_an_entry_needs_an_author_and_a_body(self, registry): + j = Journal(registry) + with pytest.raises(ValueError): + j.write("repo", "p", author="", body="x") + with pytest.raises(ValueError): + j.write("repo", "p", author="a", body=" ") + + def test_entries_are_per_resource(self, registry): + j = Journal(registry) + j.write("repo", "p", author="a", body="about p") + assert j.entries("repo", "q") == [] + + +class TestSuggestionIsAWorkListEntry: + def test_a_suggestion_lands_in_a_list_named_for_its_audience(self, registry): + j = Journal(registry) + out = j.write("repo", "p", author="peterprofile", + body="Worth using for anyone building against the Egeria API from Python.", + suggest_to=["Data Expert", "App/AI Builder"]) + assert [w["work_list"] for w in out["work_lists"]] == ["suggested-to-data-expert", "suggested-to-app-ai-builder"] + wl = WorkLists(registry).get("suggested-to-data-expert") + assert wl["display_name"] == "Suggested to Data Expert" + assert wl["derived_from"] == "journal" and wl["created_by"] == "peterprofile" + member = next(m for m in wl["members"] if m["entity_slug"] == "p") + assert member["rationale"].startswith("Worth using") + + def test_a_second_suggestion_to_the_same_audience_updates_not_duplicates(self, registry): + j = Journal(registry) + j.write("repo", "p", author="a", body="first note", suggest_to=["Consumer"]) + j.write("repo", "p", author="b", body="second note, better", suggest_to=["Consumer"]) + j.write("repo", "q", author="b", body="a different resource", suggest_to=["Consumer"]) + wl = WorkLists(registry).get("suggested-to-consumer") + members = {m["entity_slug"]: m["rationale"] for m in wl["members"]} + assert members == {"p": "second note, better", "q": "a different resource"} + # and both notes are still in p's journal -- the list holds the + # latest rationale, the journal holds every entry + assert len(j.entries("repo", "p")) == 2 + assert j.suggested_targets("repo", "p") == ["Consumer"] + + def test_audience_slugs(self): + assert audience_slug("Data Expert") == "suggested-to-data-expert" + assert audience_slug("App/AI Builder") == "suggested-to-app-ai-builder" + assert audience_slug("peterprofile") == "suggested-to-peterprofile" + + +class TestRoute: + @pytest.fixture + def client(self, registry, monkeypatch): + monkeypatch.setattr( + "resource_explorer.registry.ProjectRegistry.__init__", + lambda self, db_path=None: setattr(self, "__dict__", registry.__dict__) or None, + ) + monkeypatch.setenv("TRELLIS_ANONYMOUS_READ", "true") + from resource_explorer.web.app import app + return TestClient(app) + + def test_author_is_the_signed_in_user_and_anonymous_is_refused(self, client, monkeypatch): + monkeypatch.setattr("resource_explorer.web.routes.journal.get_current_user", lambda request: None) + assert client.post("/api/journal/repo/p", json={"body": "x"}).status_code == 401 + monkeypatch.setattr("resource_explorer.web.routes.journal.get_current_user", lambda request: {"user_id": "peterprofile"}) + r = client.post("/api/journal/repo/p", json={"body": "Note the sole-contributor risk.", "suggest_to": ["Steward"]}) + assert r.status_code == 200, r.text + assert r.json()["author"] == "peterprofile" + assert r.json()["work_lists"] == [{"target": "Steward", "work_list": "suggested-to-steward"}] + listing = client.get("/api/journal/repo/p").json() + assert len(listing["entries"]) == 1 and listing["suggested_to"] == ["Steward"] From c83c24c22e4a54c3499cf41023969b413c54b467 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Fri, 11 Sep 2026 20:17:41 -0500 Subject: [PATCH 09/37] Vendored code is not this repository's: one rule, read by every walk, recorded on the inventory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The designer's question after the member tree opened on node_modules/typescript/lib/typescript.js: does the file inventory exclude node_modules, vendor, .venv, dist, site-packages? It did not. The exclusion list existed — in line_census.py, private, read by the line census alone — so "how much code is there" was answered honestly while every other count was not. Measured on egeria-workspaces: 68% of the file inventory, 77% of the extracted symbols and 99% of the JavaScript chunks in the vector store were TypeScript's vendored library counted as the repository's own. The symbol count, the documentation ratio, the public/internal split the scope toggle had just been built on, the component graph that feeds Curate, and chat's citations all shared an inflated denominator — and chat would cite somebody else's library as this repository's, with a citation, which is worse than a wrong answer because it looks checkable. The rule moves to ingestion/vendored.py and every walk reads it: the symbol extractor, the RAG code and markdown ingestion, the data profiler, the file count, and the line census by the same name it had. Only directory segments are tested; a file called `vendor` is not vendored and a file inside `vendor/` is. The inventory RECORDS provenance rather than dropping the files — a new `vendored` column, stamped on write, DEFAULT 0 so an index made before this change is unchanged until re-indexed, and file_inventory_summary() so a surface can say "6,423 files · 4,425 vendored" — the honest number one click from the misleading one, not a silent replacement. Readers exclude vendored rows by default. The one exception that matters: the sub-resource survey reads the whole inventory and lists a vendored folder as "not worthy · vendored", because omitting it would make "34 sub-resources" silently mean "34 of 41". Its existing test — not-worthy folders are still listed, not omitted — is what caught the first version hiding it. The member tree's symbol note now says which: either the own/vendored split when the inventory carries it, or the index date and that an index before 2026-09-11 counts vendored code as the repository's own. What this commit does NOT do: re-index anything. The numbers on every screen are unchanged until each repository is re-surveyed, and that is a write to the shared registry and the vector store — the owner's call, one repo first, then look at the pane. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- .../ingestion/line_census.py | 9 +- .../resource_explorer/ingestion/pipeline.py | 11 ++- .../resource_explorer/ingestion/vendored.py | 57 ++++++++++++ .../resource_explorer/members.py | 10 ++- .../resource_explorer/registry.py | 44 +++++++-- .../surveyors/sub_surveyors/foss_scorecard.py | 2 +- .../sub_surveyors/interface_surface.py | 2 +- .../sub_surveyors/sub_resource_survey.py | 16 +++- .../resource-explorer/tests/test_registry.py | 2 +- .../resource-explorer/tests/test_vendored.py | 89 +++++++++++++++++++ 10 files changed, 217 insertions(+), 25 deletions(-) create mode 100644 packages/resource-explorer/resource_explorer/ingestion/vendored.py create mode 100644 packages/resource-explorer/tests/test_vendored.py diff --git a/packages/resource-explorer/resource_explorer/ingestion/line_census.py b/packages/resource-explorer/resource_explorer/ingestion/line_census.py index 7a0ba3bef..8565e826e 100644 --- a/packages/resource-explorer/resource_explorer/ingestion/line_census.py +++ b/packages/resource-explorer/resource_explorer/ingestion/line_census.py @@ -81,14 +81,7 @@ #: or `node_modules/` would inflate exactly the same way — and "how much code #: is there" answered with somebody else's library is a wrong answer, not a #: generous one. -_EXCLUDED_DIRS = frozenset({ - ".git", ".hg", ".svn", - ".venv", "venv", "env", "virtualenv", "site-packages", - "node_modules", "bower_components", "vendor", - "__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".tox", - "dist", "build", "target", "out", ".next", ".nuxt", - ".idea", ".vscode", ".gradle", ".eggs", -}) +from resource_explorer.ingestion.vendored import VENDORED_DIRS as _EXCLUDED_DIRS # noqa: E402 — one rule, shared since 2026-09-11 def _is_excluded(path: Path, root: Path) -> bool: diff --git a/packages/resource-explorer/resource_explorer/ingestion/pipeline.py b/packages/resource-explorer/resource_explorer/ingestion/pipeline.py index 32a5e0ef9..99d469446 100644 --- a/packages/resource-explorer/resource_explorer/ingestion/pipeline.py +++ b/packages/resource-explorer/resource_explorer/ingestion/pipeline.py @@ -11,6 +11,7 @@ from rich.progress import Progress from resource_explorer.configdata.collection_config import CollectionType +from resource_explorer.ingestion.vendored import is_vendored, is_vendored_abs from resource_explorer.vector_store_pg import MultiCollectionStore from resource_explorer.registry import ProjectRegistry, ProjectStatus @@ -449,7 +450,7 @@ def _count_repo_stats(self, local_root: Path) -> tuple[int, int]: file_count = 0 line_count = 0 for p in local_root.rglob("*"): - if not p.is_file(): + if not p.is_file() or is_vendored_abs(p, local_root): continue file_count += 1 if p.suffix.lower() in self._TEXT_SUFFIXES: @@ -538,7 +539,7 @@ def _profile_data_files(self, resource_slug: str, local_root: Path) -> None: limit_bytes = _MAX_PROFILE_SIZE_MB * 1_048_576 for p in local_root.rglob("*"): - if not p.is_file(): + if not p.is_file() or is_vendored_abs(p, local_root): continue ext = p.suffix.lstrip(".").lower() if ext not in _DATA_EXTENSIONS: @@ -592,6 +593,10 @@ def _local_files(self, local_root: Path, extensions: list[str]) -> list[tuple[st continue if p.suffix.lower() not in extensions: continue + # Somebody else's library checked in is not this repository's + # code: not its symbols, not its chunks, not a citation. + if is_vendored_abs(p, local_root): + continue try: content = p.read_text(encoding="utf-8", errors="ignore") results.append((str(p.relative_to(local_root)), content)) @@ -619,7 +624,7 @@ def _local_files_for_paths( pass elif abs_path.is_dir(): for f in abs_path.rglob("*"): - if not f.is_file() or f.suffix.lower() not in extensions: + if not f.is_file() or f.suffix.lower() not in extensions or is_vendored_abs(f, local_root): continue try: content = f.read_text(encoding="utf-8", errors="ignore") diff --git a/packages/resource-explorer/resource_explorer/ingestion/vendored.py b/packages/resource-explorer/resource_explorer/ingestion/vendored.py new file mode 100644 index 000000000..a952bded2 --- /dev/null +++ b/packages/resource-explorer/resource_explorer/ingestion/vendored.py @@ -0,0 +1,57 @@ +"""Is this file the repository's own, or somebody else's library checked in? + +One rule, read by every walk. Until 2026-09-11 it lived in line_census.py +as a private set and only the line census used it, so "how much code is +there" was answered honestly while every other count was not: on +egeria-workspaces 68% of the file inventory, 77% of the extracted symbols +and 99% of the JavaScript chunks in the vector store were +node_modules/typescript. The symbol count, the documentation ratio, the +public/internal split, the component graph fed to Curate, and chat's +citations all shared an inflated denominator — and chat would cite +somebody else's library as this repository's, with a citation, which is +worse than a wrong answer because it looks checkable. + +The inventory RECORDS provenance rather than dropping the files: a vendored +file is still a file in the repository, and "6,423 files · 4,425 vendored" +is the honest number one click from the misleading one instead of a silent +replacement. The derived analytics — symbols, chunks, profiles, the +census — skip vendored paths, because a measurement of this repository's +code should not measure TypeScript's. + +Vendored-vs-own is public-vs-internal one ring out, and "is this ours?" is +a question the catalog should ask: a repo that vendors a dependency differs +materially from one that declares it. The inventory now holds the answer. +""" +from __future__ import annotations + +from pathlib import Path + +#: Directory names that mean "not this repository's own code". Matched on +#: any path segment, so `a/b/node_modules/c.js` is vendored and so is +#: `node_modules/c.js`. Kept deliberately short and conventional; a repo +#: with an unusual vendoring layout is a case for a per-repo rule, not for +#: growing this list until it matches everything. +VENDORED_DIRS = frozenset({ + ".git", ".hg", ".svn", + ".venv", "venv", "env", "virtualenv", "site-packages", + "node_modules", "bower_components", "vendor", + "__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".tox", + "dist", "build", "target", "out", ".next", ".nuxt", + ".idea", ".vscode", ".gradle", ".eggs", +}) + + +def is_vendored(rel_path: str | Path) -> bool: + """True when any DIRECTORY on the path is a vendored name. The file's own + name is never tested, so a file called `vendor` is not vendored and a + file inside `vendor/` is.""" + parts = Path(rel_path).parts + return any(p in VENDORED_DIRS for p in parts[:-1]) + + +def is_vendored_abs(path: Path, root: Path) -> bool: + """The same test for an absolute path under a walk root.""" + try: + return is_vendored(path.relative_to(root)) + except ValueError: + return False diff --git a/packages/resource-explorer/resource_explorer/members.py b/packages/resource-explorer/resource_explorer/members.py index d8b31dedd..418c158d5 100644 --- a/packages/resource-explorer/resource_explorer/members.py +++ b/packages/resource-explorer/resource_explorer/members.py @@ -150,10 +150,18 @@ def _symbol_members(registry, slug, scope, limit) -> MemberSet: for lang, items in sorted(by_lang.items(), key=lambda kv: -sum(i["n"] for i in kv[1])): groups.append(Group(lang, sum(i["n"] for i in items), [ {"name": f["file_path"], "count": f["n"], "children_key": f"file:{f['file_path']}"} for f in items], False)) + # The honest number beside the misleading one: a project indexed before + # 2026-09-11 still counts vendored code as its own until re-indexed, and + # the inventory summary is how the rail says so rather than hiding it. + inv = registry.file_inventory_summary(slug) + provenance = (f"{inv['own']:,} of {inv['total']:,} files are this repository's own; " + f"{inv['vendored']:,} vendored and not counted." if inv["vendored"] + else f"{nfiles} files. Indexed {inv['indexed_at'][:10] or 'unknown'}; an index before " + "2026-09-11 counts vendored code as the repository's own until re-indexed.") return MemberSet("api_structure", "symbol_count", "symbols, by language and file" + ("" if scope == "all" else " — public only"), int(total or 0), scope, True, groups, - note=f"{nfiles} files. `public` is inferred from naming conventions, not declared; a maintainer's marking would be better.", + note=f"{provenance} `public` is inferred from naming conventions, not declared; a maintainer's marking would be better.", source="project_code_symbols") diff --git a/packages/resource-explorer/resource_explorer/registry.py b/packages/resource-explorer/resource_explorer/registry.py index 6a6ac27c8..9bac98d72 100644 --- a/packages/resource-explorer/resource_explorer/registry.py +++ b/packages/resource-explorer/resource_explorer/registry.py @@ -1166,6 +1166,13 @@ def _init_schema(self) -> None: # DBs created before the repo scope-narrowing funnel plan # (docs/repo-scope-narrowing-funnel.md), D5/D6 — '' = whole- # resource, matching every pre-existing row unchanged. + # Migration (2026-09-11): the inventory records whether a file is + # the repository's own or somebody else's library checked in. + # DEFAULT 0 = own, matching every pre-existing row until its + # project is re-indexed; readers exclude vendored rows by default. + inv_cols = self._get_table_columns(conn, "project_file_inventory") + if "vendored" not in inv_cols: + conn.execute("ALTER TABLE project_file_inventory ADD COLUMN vendored INTEGER DEFAULT 0") existing_findings_cols = self._get_table_columns(conn, "project_analysis_findings") if "scope_locator" not in existing_findings_cols: conn.execute( @@ -3938,6 +3945,8 @@ def upsert_file_inventory( mode data (e.g. a purely local filesystem walk) simply omits it and every row gets file_mode='' (Assessment sub-resource cataloging plan, D9 Tier 1).""" + from resource_explorer.ingestion.vendored import is_vendored + slug = self._normalize_slug(slug) indexed_at = datetime.utcnow().isoformat() modes_by_path = modes_by_path or {} @@ -3947,20 +3956,37 @@ def upsert_file_inventory( ) conn.executemany( "INSERT INTO project_file_inventory " - "(project_slug, file_path, file_size_bytes, indexed_at, file_mode) " - "VALUES (?, ?, ?, ?, ?)", + "(project_slug, file_path, file_size_bytes, indexed_at, file_mode, vendored) " + "VALUES (?, ?, ?, ?, ?, ?)", [ - (slug, path, size, indexed_at, modes_by_path.get(path, "")) + (slug, path, size, indexed_at, modes_by_path.get(path, ""), 1 if is_vendored(path) else 0) for path, size in paths_with_sizes ], ) + def file_inventory_summary(self, slug: str) -> dict: + """{total, own, vendored} — the honest number beside the misleading + one. `vendored` is 0 for a project indexed before 2026-09-11 until it + is re-indexed, and `indexed_at` says when that was.""" + slug = self._normalize_slug(slug) + with self._conn() as conn: + row = conn.execute( + "SELECT COUNT(*) AS total, " + "SUM(CASE WHEN COALESCE(vendored, 0) = 1 THEN 1 ELSE 0 END) AS vendored, " + "MAX(indexed_at) AS indexed_at " + "FROM project_file_inventory WHERE project_slug = ?", (slug,) + ).fetchone() + row = dict(row) if not isinstance(row, dict) else row + total = int(row.get("total") or 0) + vendored = int(row.get("vendored") or 0) + return {"total": total, "own": total - vendored, "vendored": vendored, "indexed_at": row.get("indexed_at") or ""} + def get_file_inventory(self, slug: str) -> list[str]: """Return all file paths from the stored inventory for a project.""" slug = self._normalize_slug(slug) with self._conn() as conn: rows = conn.execute( - "SELECT file_path FROM project_file_inventory WHERE project_slug = ?", + "SELECT file_path FROM project_file_inventory WHERE project_slug = ? AND COALESCE(vendored, 0) = 0", (slug,), ).fetchall() return [r["file_path"] for r in rows] @@ -3980,7 +4006,7 @@ def file_exists(self, slug: str, *candidate_paths: str) -> str | None: placeholders = ",".join("?" * len(candidate_paths)) rows = conn.execute( f"SELECT file_path FROM project_file_inventory " # noqa: S608 - f"WHERE project_slug = ? AND file_path IN ({placeholders})", + f"WHERE project_slug = ? AND COALESCE(vendored, 0) = 0 AND file_path IN ({placeholders})", (slug, *candidate_paths), ).fetchall() found = {r["file_path"] for r in rows} @@ -4457,7 +4483,7 @@ def query_findings_all_runs(self, slug: str, kind: str, scope_locator: str) -> l ).fetchall() return [dict(r) for r in rows] - def get_file_inventory_with_sizes(self, slug: str) -> list[dict]: + def get_file_inventory_with_sizes(self, slug: str, *, include_vendored: bool = False) -> list[dict]: """Return file paths, sizes, and git mode bits from the inventory for a project. Each dict has keys: ``file_path`` (str), ``file_size_bytes`` (int), @@ -4467,8 +4493,9 @@ def get_file_inventory_with_sizes(self, slug: str) -> list[dict]: slug = self._normalize_slug(slug) with self._conn() as conn: rows = conn.execute( - "SELECT file_path, file_size_bytes, file_mode FROM project_file_inventory " - "WHERE project_slug = ?", + "SELECT file_path, file_size_bytes, file_mode, COALESCE(vendored, 0) AS vendored " + "FROM project_file_inventory WHERE project_slug = ?" + + ("" if include_vendored else " AND COALESCE(vendored, 0) = 0"), (slug,), ).fetchall() return [ @@ -4476,6 +4503,7 @@ def get_file_inventory_with_sizes(self, slug: str) -> list[dict]: "file_path": r["file_path"], "file_size_bytes": r["file_size_bytes"] or 0, "file_mode": r["file_mode"] or "", + "vendored": bool(r["vendored"]), } for r in rows ] diff --git a/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/foss_scorecard.py b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/foss_scorecard.py index 1dc4fc1a9..e985ecaac 100644 --- a/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/foss_scorecard.py +++ b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/foss_scorecard.py @@ -360,7 +360,7 @@ def run(self) -> list[Annotation]: with self.registry._conn() as conn: rows = conn.execute( "SELECT file_path FROM project_file_inventory " - "WHERE project_slug = ?", (slug,)).fetchall() + "WHERE project_slug = ? AND COALESCE(vendored, 0) = 0", (slug,)).fetchall() if rows: paths = [r["file_path"] for r in rows] diff --git a/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/interface_surface.py b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/interface_surface.py index 40152be2b..55bbf94ed 100644 --- a/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/interface_surface.py +++ b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/interface_surface.py @@ -186,7 +186,7 @@ def run(self) -> list[Annotation]: slug = self.project.slug with self.registry._conn() as conn: paths = [r["file_path"] for r in conn.execute( - "SELECT file_path FROM project_file_inventory WHERE project_slug = ?", + "SELECT file_path FROM project_file_inventory WHERE project_slug = ? AND COALESCE(vendored, 0) = 0", (slug,)).fetchall()] deps = [d.get("dep_name") for d in (self.registry.query_dependencies(slug) or [])] diff --git a/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/sub_resource_survey.py b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/sub_resource_survey.py index 855b601d6..cdf979425 100644 --- a/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/sub_resource_survey.py +++ b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/sub_resource_survey.py @@ -146,7 +146,11 @@ def step_name(self) -> str: def run(self) -> list[Annotation]: results: list[Annotation] = [] try: - inventory = self.registry.get_file_inventory_with_sizes(self.project.slug) + # The WHOLE inventory, vendored included: a vendored folder is a + # real sub-resource of this repository, and the honest listing is + # "node_modules — not worthy — vendored", not its absence. Omitting + # it would make "34 sub-resources" silently mean "34 of 41". + inventory = self.registry.get_file_inventory_with_sizes(self.project.slug, include_vendored=True) if not inventory: # confidence=50 used to be the only signal that this was a # non-answer, and 50 is not a vocabulary — it reads as a @@ -251,16 +255,24 @@ def _build_findings(self, inventory: list[dict]) -> list[dict]: # Depth-1 folders. folder_counts: dict[str, int] = {} + vendored_tops: set[str] = set() for entry in inventory: path = entry["file_path"].replace("\\", "/") if "/" not in path: continue top = path.split("/", 1)[0] folder_counts[top] = folder_counts.get(top, 0) + 1 + if entry.get("vendored"): + vendored_tops.add(top) folder_entries: dict[str, dict] = {} for folder, count in sorted(folder_counts.items()): - if count < _FOLDER_MIN_FILES: + if folder in vendored_tops: + # Somebody else's library checked in. Listed, and said why — + # a promotion candidate this is not, but a fact about the + # repository it is. + worthy, reason = False, "vendored" + elif count < _FOLDER_MIN_FILES: worthy, reason = False, "too_small" elif count > _FOLDER_MAX_FILES: worthy, reason = False, "too_broad" diff --git a/packages/resource-explorer/tests/test_registry.py b/packages/resource-explorer/tests/test_registry.py index d730101b9..5ea8a4e28 100644 --- a/packages/resource-explorer/tests/test_registry.py +++ b/packages/resource-explorer/tests/test_registry.py @@ -717,7 +717,7 @@ def test_get_file_inventory_with_sizes_includes_size_and_mode(self, db, sample_p sample_project.slug, [("a.py", 123)], modes_by_path={"a.py": "100644"}, ) rows = db.get_file_inventory_with_sizes(sample_project.slug) - assert rows == [{"file_path": "a.py", "file_size_bytes": 123, "file_mode": "100644"}] + assert rows == [{"file_path": "a.py", "file_size_bytes": 123, "file_mode": "100644", "vendored": False}] def test_repeated_upsert_replaces_not_appends(self, db, sample_project): db.add(sample_project) diff --git a/packages/resource-explorer/tests/test_vendored.py b/packages/resource-explorer/tests/test_vendored.py new file mode 100644 index 000000000..5ac6c73cc --- /dev/null +++ b/packages/resource-explorer/tests/test_vendored.py @@ -0,0 +1,89 @@ +"""One rule for "is this the repository's own code?" — ingestion/vendored.py — +and the places it must be read. + +Before 2026-09-11 the rule was private to the line census, and on +egeria-workspaces 68% of the inventory, 77% of the symbols and 99% of the +JavaScript chunks were node_modules/typescript. These pin: the rule itself; +that the inventory RECORDS provenance rather than dropping files; that +readers exclude vendored rows by default; and that the symbol/chunk walk +skips them. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from resource_explorer.ingestion.vendored import VENDORED_DIRS, is_vendored, is_vendored_abs +from resource_explorer.registry import Project, ProjectRegistry + + +class TestTheRule: + def test_any_directory_segment_counts_and_the_filename_never_does(self): + assert is_vendored("node_modules/typescript/lib/typescript.js") + assert is_vendored("plugins/x/node_modules/a.js") + assert is_vendored("site-packages/requests/api.py") + assert not is_vendored("src/vendor.py") # a FILE named vendor + assert not is_vendored("src/app/main.py") + assert not is_vendored("README.md") + + def test_the_line_census_reads_the_same_set(self): + from resource_explorer.ingestion import line_census + assert line_census._EXCLUDED_DIRS is VENDORED_DIRS + + def test_abs_form_is_relative_to_the_walk_root(self, tmp_path): + (tmp_path / "node_modules").mkdir() + f = tmp_path / "node_modules" / "x.js"; f.write_text("") + assert is_vendored_abs(f, tmp_path) + assert not is_vendored_abs(tmp_path / "x.js", tmp_path) + + +@pytest.fixture +def db(tmp_path): + r = ProjectRegistry(db_path=str(tmp_path / "t.db")) + r.add(Project(slug="p", display_name="P", github_url="https://github.com/x/p", description="")) + return r + + +class TestInventoryRecordsProvenance: + def test_vendored_files_are_kept_marked_and_excluded_from_readers(self, db): + db.upsert_file_inventory("p", [("src/a.py", 10), ("node_modules/t.js", 99), ("docs/x.md", 5)]) + # recorded, not dropped + assert db.file_inventory_summary("p") == {"total": 3, "own": 2, "vendored": 1, + "indexed_at": db.file_inventory_summary("p")["indexed_at"]} + # readers see the repository's own files by default + assert sorted(db.get_file_inventory("p")) == ["docs/x.md", "src/a.py"] + + def test_a_pre_migration_row_reads_as_own_until_reindexed(self, db): + """DEFAULT 0 means an old inventory is unchanged by the migration — + and the summary's indexed_at is how a reader tells old from new.""" + db.upsert_file_inventory("p", [("src/a.py", 10)]) + with db._conn() as conn: + conn.execute("UPDATE project_file_inventory SET vendored = NULL WHERE project_slug = 'p'") + assert db.get_file_inventory("p") == ["src/a.py"] + assert db.file_inventory_summary("p")["vendored"] == 0 + + +class TestTheWalks: + def test_local_files_skips_vendored_paths(self, tmp_path): + from resource_explorer.ingestion.pipeline import IngestionPipeline + (tmp_path / "src").mkdir(); (tmp_path / "node_modules" / "lib").mkdir(parents=True) + (tmp_path / "src" / "own.py").write_text("x = 1\n") + (tmp_path / "node_modules" / "lib" / "theirs.py").write_text("y = 2\n") + pipe = IngestionPipeline.__new__(IngestionPipeline) # no store, no registry: the walk is pure + got = dict(pipe._local_files(tmp_path, [".py"])) + assert list(got) == ["src/own.py"] + + +class TestSubResourceSurveyStillListsVendored: + def test_a_vendored_folder_is_listed_not_worthy_with_the_reason(self, db): + """The one reader that must see vendored rows: a sub-resource survey + that omitted node_modules would make "34 sub-resources" silently + mean "34 of 41". Listed, and the reason is the honest one.""" + from resource_explorer.surveyors.sub_surveyors.sub_resource_survey import SubResourceSurveyor + db.upsert_file_inventory("p", [(f"node_modules/lib/f{i}.js", 1) for i in range(12)] + + [(f"src/m{i}.py", 1) for i in range(12)]) + rows = db.get_file_inventory_with_sizes("p", include_vendored=True) + assert {r["file_path"].split("/")[0]: r["vendored"] for r in rows} == {"node_modules": True, "src": False} + # and the default reader still hides it from everyone else + assert all(not r["file_path"].startswith("node_modules") for r in db.get_file_inventory_with_sizes("p")) From 298ff67a5adf721ab7efe2200ff75257df9a13ab Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Fri, 11 Sep 2026 20:34:29 -0500 Subject: [PATCH 10/37] RE compiler: a yes/no answer must carry the evidence line it rests on The one variable of experiment run 8. Runs 5-7 gave the answering model an honest cve_scan headline ("not checked: 0 of 61 declared dependenc(ies) could be queried") and, from run 7, the catalog's own caveat directly under it - and the 8B answerer still replied "No", then "Yes", to "Are there outstanding CVEs?" on docling. Three compilers' worth of evidence did not move a one-word answer, so the instructions (FULL and SUMMARY rungs; the bare rung is the budget floor and is unchanged) now say: give the yes or no and then the analysis, the value, and any coverage limit or caveat shown beside it; a bare yes or no is not an answer. Measured by run 8 against run 7; unmeasured until then. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Dan Wolfson --- .../resource_explorer/context_compile.py | 17 ++++++++++++++--- .../tests/test_context_compile.py | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/resource-explorer/resource_explorer/context_compile.py b/packages/resource-explorer/resource_explorer/context_compile.py index 3097fe794..8c3fa16b9 100644 --- a/packages/resource-explorer/resource_explorer/context_compile.py +++ b/packages/resource-explorer/resource_explorer/context_compile.py @@ -55,9 +55,19 @@ "Answer using only the evidence below. Every section states which analysis " "produced it. A section marked 'abridged' shows only the first few entries " "of each list or mapping — its counts and entries are partial, so do not " - "report an abridged list as complete. If the evidence does not answer the " - "question, say so and name what is missing — do not infer from absence." + "report an abridged list as complete. If the question asks yes or no, give " + "the yes or no and then the evidence line it rests on — the analysis, the " + "value, and any coverage limit or caveat shown beside it; a bare yes or no " + "is not an answer. If the evidence does not answer the question, say so and " + "name what is missing — do not infer from absence." ) +#: The yes/no sentence is the ONE variable of experiment run 8 (2026-09-11). +#: Runs 5-7 put an honest headline ("not checked: 0 of 61 declared +#: dependenc(ies) could be queried") and the catalog's own caveat directly +#: under it, and the 8B answerer still replied "No", then "Yes", to "Are +#: there outstanding CVEs?" on docling. Three compilers' worth of evidence +#: did not move a one-word answer, so the instruction now asks for the line +#: the answer rests on. Measured, not assumed: see the experiment doc. #: The same instructions at the packer's SUMMARY rung, for budgets too small #: to carry the template. Instructions are required, so without a shorter @@ -65,7 +75,8 @@ #: one section that used to be exempt from the ladder now climbs it too. _INSTRUCTIONS_SHORT = ( "Answer only from the evidence below; name the analysis behind each point. " - "'Abridged' sections show first entries only. If it does not answer, say " + "'Abridged' sections show first entries only. A yes/no answer must state " + "the evidence line and its coverage limit. If it does not answer, say " "which analysis would and whether it has run. Do not infer from absence." ) diff --git a/packages/resource-explorer/tests/test_context_compile.py b/packages/resource-explorer/tests/test_context_compile.py index f9398fc3e..bedd117c7 100644 --- a/packages/resource-explorer/tests/test_context_compile.py +++ b/packages/resource-explorer/tests/test_context_compile.py @@ -1161,3 +1161,22 @@ def test_the_short_rung_carries_a_shorter_caveat(self, monkeypatch): assert rung in {"SUMMARY", "IDENTIFIERS"} if rung == "SUMMARY": assert "Caveat: DECLARED" in c.text and "Caveat for this question" not in c.text + + +class TestAYesNoAnswerMustCarryItsEvidenceLine: + """Run 8's one variable. Runs 5-7: honest headline, caveat beside it, and + the answer to "Are there outstanding CVEs?" on docling was still a bare + "No" and then a bare "Yes".""" + + def test_full_and_short_rungs_ask_for_the_line(self): + from resource_explorer.context_compile import ( + _INSTRUCTIONS, _INSTRUCTIONS_SHORT, _INSTRUCTIONS_BARE) + assert "a bare yes or no is not an answer" in _INSTRUCTIONS + assert "coverage limit or caveat" in _INSTRUCTIONS + assert "yes/no answer must state the evidence line" in _INSTRUCTIONS_SHORT + # The bare rung is the budget floor and stays as it was. + assert "yes" not in _INSTRUCTIONS_BARE.lower() + + def test_it_reaches_the_packed_text(self): + c = compile_context(_registry({"repo_conventions": [_finding("a")]}), "x", "q", budget=6000) + assert "a bare yes or no is not an answer" in c.text From 611e40b53d81ace3f18c01a25ac56064b960f7b0 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Fri, 11 Sep 2026 20:36:56 -0500 Subject: [PATCH 11/37] The walk #36 missed, and a guard so the next one cannot hide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-surveying egeria-workspaces after #36 gave the honest inventory — 1,998 own files of 6,423 — and then put every vendored symbol back: 26,564 again, one survey after the profile refresh had taken it to 5,910. The survey step's symbol extractor carries its own copy of IngestionPipeline._local_files, "duplicated rather than imported" by its own comment, and #36 fixed the original and not the copy. Four walks that measure the repository's own content now read the rule: the survey step's symbol extractor (the one that regressed), manifest detection (a vendored dependency's package.json is not this repository's manifest), the conventions parser (doc_breadth counted node_modules' markdown — 175 of egeria-workspaces' .md files), and the PDF walks. Architecture recovery keeps its own exclusion module and dependency_parser its ad-hoc vendor skip; both are allowlisted by name. And the guard: a test that lists every whole-tree walk under surveyors/ and ingestion/ and requires each file to consult the rule or be on that short allowlist. Made to fail on purpose first — renaming the call in one file names that file — so it is known to fire, not assumed to. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- .../resource_explorer/ingestion/pipeline.py | 5 +++ .../ingestion/repo_conventions_parser.py | 7 +++- .../surveyors/sub_surveyors/manifest_parse.py | 5 ++- .../sub_surveyors/symbol_extraction.py | 6 +++ .../resource-explorer/tests/test_vendored.py | 38 +++++++++++++++++++ 5 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/resource-explorer/resource_explorer/ingestion/pipeline.py b/packages/resource-explorer/resource_explorer/ingestion/pipeline.py index 99d469446..d33197ab6 100644 --- a/packages/resource-explorer/resource_explorer/ingestion/pipeline.py +++ b/packages/resource-explorer/resource_explorer/ingestion/pipeline.py @@ -14,6 +14,7 @@ from resource_explorer.ingestion.vendored import is_vendored, is_vendored_abs from resource_explorer.vector_store_pg import MultiCollectionStore from resource_explorer.registry import ProjectRegistry, ProjectStatus +from resource_explorer.ingestion.vendored import is_vendored_abs log = logging.getLogger(__name__) @@ -843,12 +844,16 @@ def _handle(display: str, abs_str: str) -> None: pass for path in local_root.rglob("*.pdf"): + if is_vendored_abs(path, local_root): + continue _handle(str(path.relative_to(local_root)), str(path)) for display, abs_path in (extra_paths or []): if abs_path.is_file() and abs_path.suffix.lower() == ".pdf": _handle(f"{display}/{abs_path.name}", str(abs_path)) elif abs_path.is_dir(): for pdf in abs_path.rglob("*.pdf"): + if is_vendored_abs(pdf, local_root): + continue _handle(f"{display}/{pdf.relative_to(abs_path)}", str(pdf)) from resource_explorer.ingestion.artifact_tree_sink import ( diff --git a/packages/resource-explorer/resource_explorer/ingestion/repo_conventions_parser.py b/packages/resource-explorer/resource_explorer/ingestion/repo_conventions_parser.py index c89d1c0a1..6496f0ae7 100644 --- a/packages/resource-explorer/resource_explorer/ingestion/repo_conventions_parser.py +++ b/packages/resource-explorer/resource_explorer/ingestion/repo_conventions_parser.py @@ -22,6 +22,7 @@ import re from pathlib import Path +from resource_explorer.ingestion.vendored import is_vendored_abs # ── security_policy_content ───────────────────────────────────────────── # Checked in priority order — first file found wins (matches @@ -70,7 +71,7 @@ def _any_file_present(local_root: Path, filenames: tuple[str, ...]) -> list[str] (monorepos, a `docker/` folder, etc.), not always at repo root.""" found = [] for p in local_root.rglob("*"): - if p.is_file() and p.name in filenames: + if p.is_file() and p.name in filenames and not is_vendored_abs(p, local_root): found.append(str(p.relative_to(local_root))) return found @@ -158,7 +159,9 @@ def _doc_breadth(self, local_root: Path) -> dict: md_txt_count = 0 docs_dir_present = False for p in local_root.rglob("*"): - if not p.is_file(): + # doc_breadth counts READMEs and .md/.txt across the tree; 175 + # of egeria-workspaces' markdown files were node_modules'. + if not p.is_file() or is_vendored_abs(p, local_root): continue rel = str(p.relative_to(local_root)).replace("\\", "/") name = _basename(rel) diff --git a/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/manifest_parse.py b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/manifest_parse.py index b42cccdca..b20ddd44c 100644 --- a/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/manifest_parse.py +++ b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/manifest_parse.py @@ -51,6 +51,7 @@ from resource_explorer.step_outcome import RECOVERED, UNVERIFIED, StepOutcome, no_signal from resource_explorer.surveyors.base_surveyor import BaseSurveyor from resource_explorer.surveyors.survey_report import Annotation, ResourceMeasureAnnotation +from resource_explorer.ingestion.vendored import is_vendored_abs log = logging.getLogger(__name__) @@ -79,7 +80,9 @@ def _manifests_present(local_root: Path) -> list[str]: or was never selected.""" found: set[str] = set() for p in local_root.rglob("*"): - if p.is_file() and p.name in _MANIFESTS: + # A vendored dependency's own package.json is not this repository's + # manifest; dependency_parser already skips these ad hoc. + if p.is_file() and p.name in _MANIFESTS and not is_vendored_abs(p, local_root): found.add(p.name) return sorted(found) diff --git a/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/symbol_extraction.py b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/symbol_extraction.py index e20be7f6b..5140d16f7 100644 --- a/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/symbol_extraction.py +++ b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/symbol_extraction.py @@ -33,6 +33,7 @@ from resource_explorer.surveyors.base_surveyor import BaseSurveyor from resource_explorer.step_outcome import StepOutcome, no_signal from resource_explorer.surveyors.survey_report import Annotation, ResourceMeasureAnnotation +from resource_explorer.ingestion.vendored import is_vendored_abs log = logging.getLogger(__name__) @@ -58,6 +59,11 @@ def _local_files(local_root: Path, extensions: list[str]) -> list[tuple[str, str for p in local_root.rglob("*"): if not p.is_file() or p.suffix.lower() not in extensions: continue + # The duplicate of IngestionPipeline._local_files that PR #36 missed: + # this walk put 20,654 vendored symbols back one survey after the + # profile refresh had removed them. Same rule, same reason. + if is_vendored_abs(p, local_root): + continue try: content = p.read_text(encoding="utf-8", errors="ignore") results.append((str(p.relative_to(local_root)), content)) diff --git a/packages/resource-explorer/tests/test_vendored.py b/packages/resource-explorer/tests/test_vendored.py index 5ac6c73cc..fd8406d88 100644 --- a/packages/resource-explorer/tests/test_vendored.py +++ b/packages/resource-explorer/tests/test_vendored.py @@ -87,3 +87,41 @@ def test_a_vendored_folder_is_listed_not_worthy_with_the_reason(self, db): assert {r["file_path"].split("/")[0]: r["vendored"] for r in rows} == {"node_modules": True, "src": False} # and the default reader still hides it from everyone else assert all(not r["file_path"].startswith("node_modules") for r in db.get_file_inventory_with_sizes("p")) + + +class TestEveryWalkReadsTheRule: + """PR #36 fixed the pipeline's walks and missed the survey step's + duplicate of one of them, which put 20,654 vendored symbols back one + survey later. A walk that measures the repository's content and does + not consult the rule is the shape of that regression, so this lists + every whole-tree walk and requires each file to read `is_vendored`, + or to be on the short allowlist of walks that have their own rule. + """ + ALLOWED_WITHOUT = { + "ingestion/line_census.py", # reads VENDORED_DIRS by its old name + "ingestion/dependency_parser.py", # excludes vendor/node_modules ad hoc per manifest + "surveyors/arch_recovery/spring_app.py", # arch recovery has its own exclusion module + "surveyors/arch_recovery/exclusion.py", + } + + def test_whole_tree_walks_consult_the_rule(self): + import re + from pathlib import Path + root = Path(__file__).resolve().parents[1] / "resource_explorer" + offenders = [] + for f in list((root / "surveyors").rglob("*.py")) + list((root / "ingestion").rglob("*.py")): + rel = str(f.relative_to(root)) + src = f.read_text(encoding="utf-8") + walks = re.findall(r'\.rglob\("\*"\)|\.rglob\(\'\*\'\)|os\.walk\(', src) + if not walks or rel in self.ALLOWED_WITHOUT: + continue + if "is_vendored" not in src and "VENDORED_DIRS" not in src: + offenders.append(rel) + assert offenders == [], f"whole-tree walks that never consult the vendored rule: {offenders}" + + def test_the_survey_symbol_walk_skips_vendored(self, tmp_path): + from resource_explorer.surveyors.sub_surveyors.symbol_extraction import _local_files + (tmp_path / "src").mkdir(); (tmp_path / "node_modules" / "t").mkdir(parents=True) + (tmp_path / "src" / "own.py").write_text("x = 1\n") + (tmp_path / "node_modules" / "t" / "theirs.py").write_text("y = 2\n") + assert [p for p, _ in _local_files(tmp_path, [".py"])] == ["src/own.py"] From ba240d61dc5727f91b991213c569aa49102bea60 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Fri, 11 Sep 2026 21:17:20 -0500 Subject: [PATCH 12/37] =?UTF-8?q?RE=20docs:=20coverage=20audit=20=E2=80=94?= =?UTF-8?q?=20questions,=20analyses,=20steps=20and=20surveys,=20measured?= =?UTF-8?q?=20against=20intent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A coverage audit the project owner asked for, measured against the live registry (61 repos) and the catalogs at 004cb66. Every number came from a query, not a read; the audit scripts are recorded in the doc's provenance. The wiring is sound: zero dangling analysis ids, zero unrunnable analyses, zero question-answering analyses that have never run, every registered step in a survey definition. The coverage question is therefore not "does the chain work". Two framing decisions from the project owner reshaped this from its first draft, and both are recorded at the top so the numbers are read against the right yardstick. Analyses serve two purposes, not one - the catalogued questions AND ad-hoc Chat questions of the kind Egeria Advisor showed people actually ask - so manifest_parse and code_symbol_extraction are not orphans but among the most load-bearing analyses in the catalog. And the goal is not to run every survey on every repo but the right surveys to meet the intent of investigating one; a repo that Scouting shows to be uninteresting is stopped. Measured against that yardstick - the 14 repos a person chose to keep investigating - the gap is concentrated in exactly the tiers a keep decision is meant to unlock: 13 of 14 cannot answer IP provenance or telemetry, 12 of 14 secrets handling, 9 of 14 CVEs, dependencies, Scorecard or authorship concentration. Discovery and Scouting questions are answerable on most of them. The funnel is respecting its stops: 9 deep-tier runs on the 2 stopped repos. The 44 repos with no disposition are pending, not decided - the owner has not analysed them yet, which says nothing about whether they are interesting. Questions with no survey or analytic at all: three (similar repos, upgrade process, AI/ML licensing constraints), plus one partial. The other seven with no machine answer are human by design. Seven improvements in order, the first two needing no code. What the audit cannot see is stated: answer quality, chat coverage itself, Egeria-side analyses, and non-repo resources. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- .../docs/coverage-audit-2026-09-11.md | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 packages/resource-explorer/docs/coverage-audit-2026-09-11.md diff --git a/packages/resource-explorer/docs/coverage-audit-2026-09-11.md b/packages/resource-explorer/docs/coverage-audit-2026-09-11.md new file mode 100644 index 000000000..deb41dfbf --- /dev/null +++ b/packages/resource-explorer/docs/coverage-audit-2026-09-11.md @@ -0,0 +1,197 @@ +# Coverage audit — questions ↔ analyses ↔ steps ↔ surveys + +Measured 2026-09-11 against the live registry (61 repos) and the committed +catalogs at `004cb66`. Every number came from a query, not a read. + +**Two framing decisions from the project owner shape this document, and the +first draft got both wrong.** They are recorded here so the numbers are read +against the right yardstick: + +1. **Analyses serve two purposes, not one.** Beyond answering the catalogued + questions, they exist to make the Chat interface useful for *ad hoc* + questions — "how many classes, name them, how is this related to that, how + many files, how big are they" — the shape of question Egeria Advisor showed + users actually ask. An analysis that answers no catalogued question is not + an orphan; it may be exactly the data a chat answer needs. +2. **The goal is not to run every survey on every repo.** It is to run the + *right* surveys to meet the intent of investigating a repo. A repo that + Scouting shows to be uninteresting is stopped, and no deeper tier is owed + to it. Coverage is therefore measured against **repos a person chose to + keep investigating**, not against the estate. + +## Inventory + +| | count | +|---|---| +| questions (`question_catalog.yaml`) | 52 | +| analyses (`analysis_catalog.yaml`, repo) | 36 | +| steps (`STEP_REGISTRY`) | 40 | +| survey definitions (`repo_survey_types.csv`) | 10 | +| repos in the registry | 61 | +| … with any disposition | 17 | +| … disposition = keep investigating (`investigating`/`tracking`/`using`/`recommended`) | **14** | +| … disposition = stopped (`ignored`/`abandoned`) | 2 | +| … not yet analysed | **44** | + +## 1. The wiring is sound + +| check | result | +|---|---| +| questions claiming `kind: analysis` with no `analysis_ids` | 0 | +| `analysis_ids` naming an analysis the catalog does not have | 0 | +| analyses cited by a question that own or derive no runnable step | 0 | +| analyses that answer a question and have **never run** | 0 | +| registered steps in no survey definition | 0 | +| survey rows attributing to nothing | 0 | + +"Does the chain work" is yes, end to end. + +## 2. Questions with no survey or analytic + +11 of 52 have no machine answer. They split three ways, and only one way is a +gap: + +| kind | n | questions | reading | +|---|---|---|---| +| `human`, Analysis/Enrichment | 5 | monitoring fit, security fit, infrastructure fit, skills, governance fit | **by design** — rule 17: Enrichment is served by `context.py`, not analyses | +| `human`, Analysis | 2 | *do we already support these dependencies?* · *do we know the cost to run it?* | human by nature of the question; a machine could *inform* but not answer | +| `partial`, Analysis | 1 | *what kinds of integrations does it support?* | partially covered; not enumerated here | +| **`gap`, Analysis** | **3** | ***what are similar repos, how does this differ?* · *what is the upgrade process?* · *for AI/ML assets, what licensing or usage constraints apply?*** | **nothing exists** | + +So the honest count of questions with no survey or analytic is **three**, with +one more partially covered. + +## 3. Coverage per stage + +`direct`, `chart` and `mixed` count as machine-answerable alongside `analysis`: + +| stage | questions | machine | human/gap | analysis-Qs exercised | +|---|---|---|---|---| +| Analysis | 22 | 16 | 6 | 9 of 9 | +| Discovery | 12 | 12 | 0 | 4 of 4 | +| Assessment | 7 | 7 | 0 | 6 of 6 | +| Scouting | 5 | 5 | 0 | 4 of 4 | +| Analysis/Enrichment | 5 | 0 | 5 | — | +| Automate | 1 | 1 | 0 | — | + +Every analysis-backed question has been exercised on at least one repo. + +## 4. The actual gap — measured against intent + +For the **14 repos a person chose to keep investigating**, how many *cannot* +answer each deeper-tier question because a cited analysis has not run there? +(A derived analysis inherits its source's runs — `architecture_diagram` counts +where `architecture_recovery` ran.) + +| kept repos that cannot answer | question | missing | +|---|---|---| +| 13 of 14 | Is IP provenance managed via DCO/CLA? | `contribution_provenance` | +| 13 of 14 | Telemetry / phone-home mechanisms? | `telemetry_scan` | +| 12 of 14 | How are secrets and credentials handled? | `secret_scan` | +| 11 of 14 | What APIs and code symbols does it expose? | `interface_surface`, `api_structure` | +| 9 of 14 | Are there outstanding CVEs? | `cve_scan` | +| 9 of 14 | What dependencies does this require? | `dependency_analysis` | +| 9 of 14 | OpenSSF Scorecard-style score? | `foss_scorecard` | +| 9 of 14 | How concentrated is authorship? | `chaoss_metrics` | +| 9 of 14 | How much code, how complex? | `language_file_classification`, `api_structure` | +| 8 of 14 | Clear process for reporting security issues? | `security_scan`, `repo_conventions` | +| 8 of 14 | OpenSSF Best Practices badge? | `cii_badge` | + +Discovery- and Scouting-tier questions are answerable on most kept repos; the +gap is concentrated in Assessment and Analysis — the tiers a "keep +investigating" decision is supposed to unlock. + +**This is the finding.** Not "the estate is uncovered" — 44 repos simply +have not been analysed yet, which says nothing about whether they are +interesting (project owner, 2026-09-11: "I haven't spent the time to analyze +all of the repos yet — that doesn't mean they aren't interesting, just not +done") — but *"for the repos already decided worth it, the deeper questions +mostly cannot be answered"*. Fourteen repos, ten questions, a handful of +analyses. The 44 will each produce their own version of this gap as they are +reached. + +### The funnel is respecting its stops + +Deeper-tier analyses that ran on repos later marked `ignored`/`abandoned`: **9 +runs, all on `amundsen`**, on 2 stopped repos. Effectively no work is being +spent past a stop decision. + +### Related: the funnel-cost spec's §2 said "not narrowing" + +That measurement (2026-09-10) found 100% discovery→analysis retention on the +26 *attributable* repos. Read against this document's yardstick, the two are +compatible: the funnel narrows by **disposition** (14 kept, 2 stopped, 44 +not yet reached), and the deeper tiers are applied — thinly — to the kept +ones. What §2 could not see, because it read only the activity log, is that +most repos have not entered the funnel yet. + +## 5. Analyses answering no catalogued question — read against purpose 1 + +| analysis | intent | ran on | reading | +|---|---|---|---| +| `manifest_parse` | analysis | 16 | writes `project_dependencies`; feeds `dependency_analysis` *and* chat ("what does this depend on") | +| `code_symbol_extraction` | analysis | 8 | writes `project_code_symbols`; feeds `interface_surface`/`api_structure` *and* chat ("how many classes, name them") | +| `sub_resource_survey` | analysis | 8 | infrastructure | +| `refresh_plan` | analysis | 6 | infrastructure | +| `security_summary` | assessment | 3 | no catalogued question and no evident chat role — **worth a decision**, not a deletion | +| `repo_profile_refresh` | scouting | 0 | owns no steps, never ran — **dead entry** | + +The first draft called the first two "orphans". Under purpose 1 they are +among the most load-bearing analyses in the catalog. The catalog has no field +that says so, which is why they *look* orphaned to any tool that only joins +against `question_catalog.yaml`. + +## 6. Steps owned by no analysis — same reading + +`repo_file_inventory`, `repo_file_size`, `repo_git_statistics`, +`repo_homepage` run in surveys with no owning analysis. Under purpose 1 they +are plausibly the chat data for "how many files, what files, how big" — but a +survey definition built from them alone would attribute to `unknown-analysis`, +and their cost is invisible to `get_analysis_last_run`. Giving them an owner +costs nothing and makes them visible; it does not change what they do. + +## 7. Concentration + +`repo_conventions` answers **7 of 23** analysis-backed questions — 30% — and +is a zero-fetch Discovery analysis. One analysis's correctness carries nearly a +third of the machine-answerable catalogued surface. + +## What to improve, in order + +1. **Close the deeper-tier gap on the 14 kept repos.** This is bounded work: + `RepoAssessmentSurvey` (10 steps) and `RepoComplianceSurvey` (14) on + fourteen repos, not sixty-one. The freshness gate declines what is already + current. Whether that is a one-off or a rule — "a `keep investigating` + disposition schedules the deeper surveys" — is a product decision, and the + second is the one that keeps this from recurring. +2. **The 44 not-yet-analysed repos are pending, not out of scope.** Each will + need Scouting, then a disposition, then — if kept — the deeper surveys. + Item 1's "a `keep investigating` disposition schedules the deeper surveys" + rule is what stops the gap in §4 reappearing for every one of them as it + is reached. +3. **Give the catalog a way to say "this exists for chat."** A `serves:` + field (`questions` / `chat` / `feeder` / `infrastructure`) on + `AnalysisCatalogEntry` would stop `manifest_parse` and + `code_symbol_extraction` reading as orphans to every future audit, and make + `security_summary`'s status a stated decision rather than an omission. +4. **Own the four unowned steps** under a Scouting-tier profile analysis so + their runs attribute. +5. **Retire `repo_profile_refresh`** (dead) and **decide `security_summary`**. +6. **Only then consider new analyses**, and only for the three `gap` + questions. *Upgrade process* and *AI/ML licensing* are plausibly derivable + from data already collected (release notes; license and model-card files). + *Similar repos* is a different kind of problem and should not be built on + the strength of one question. +7. **Look at `repo_conventions`' seven checks** for independence — not to + split it, but to know whether one broken check takes seven answers with it. + +## What this audit could not see + +- **Answer quality.** "Ran on N repos" is presence, not correctness; a run + that produced `unverified` for every check counts here as coverage. +- **Chat coverage itself.** Purpose 1 is real and is not measured here — there + is no catalogue of ad-hoc chat questions to audit against. If one existed + (even a sample of what people actually asked), the "orphan" analyses could + be scored the same way as the catalogued questions. +- **Egeria-side analyses.** `include_egeria_live=False` throughout. +- **Database and filesystem resources.** Repos only. From a9e6fded2220e20a436abb669600809da1236a77 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Fri, 11 Sep 2026 21:17:46 -0500 Subject: [PATCH 13/37] =?UTF-8?q?RE=20docs:=20coverage=20audit=20=E2=80=94?= =?UTF-8?q?=20note=20the=20pre/post-#36=20regime=20split=20in=20what=20it?= =?UTF-8?q?=20cannot=20see?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit's reach numbers are presence-of-a-run and unaffected, but #36 and #39 changed what 'own code' means for every content measure, and only one repo has been re-surveyed onto the new regime. A reader extending this into answer content needs to know that before comparing repos. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- .../docs/coverage-audit-2026-09-11.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/resource-explorer/docs/coverage-audit-2026-09-11.md b/packages/resource-explorer/docs/coverage-audit-2026-09-11.md index deb41dfbf..e9fe48904 100644 --- a/packages/resource-explorer/docs/coverage-audit-2026-09-11.md +++ b/packages/resource-explorer/docs/coverage-audit-2026-09-11.md @@ -189,6 +189,19 @@ third of the machine-answerable catalogued surface. - **Answer quality.** "Ran on N repos" is presence, not correctness; a run that produced `unverified` for every check counts here as coverage. +- **Which regime a repo's numbers are in.** As of `90a0716` — the base this + audit was taken at — #36 and #39 changed what "the repository's own code" + means everywhere a walk measures it: vendored paths (`node_modules`, + `vendor`, `.venv`, `dist`, `site-packages`, the set in + `ingestion/vendored.py`) are now skipped by the symbol extractor, RAG + ingestion, the data profiler, manifest detection, the conventions parser and + the file count, with the inventory carrying a `vendored` flag. On + `egeria-workspaces` that took symbols 26,564 → 5,910 and files 6,423 → 1,998 + on re-survey. Every other repo keeps its pre-#36 figures until re-surveyed. + The *presence* counts here are unaffected — a run is a run — but any + extension of this audit into answer **content** is describing two regimes + depending on when each repo was last indexed, and only `egeria_workspaces_git` + is on the new one. (Flagged by the session that landed #36/#39.) - **Chat coverage itself.** Purpose 1 is real and is not measured here — there is no catalogue of ad-hoc chat questions to audit against. If one existed (even a sample of what people actually asked), the "orphan" analyses could From 31d359e02a6f92b96fbe949ac5c500010e8b9efe Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Fri, 11 Sep 2026 21:20:44 -0500 Subject: [PATCH 14/37] =?UTF-8?q?RE=20docs:=20coverage=20audit=20=E2=80=94?= =?UTF-8?q?=20the=20three=20gap=20questions=20are=20three=20kinds=20of=20w?= =?UTF-8?q?ork,=20not=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the project owner's 2026-09-11 directions on the questions with no survey or analytic. 'Do we already support these dependencies' has an Egeria starting point - project_dependencies cross-referenced against what Egeria already catalogues - that still needs corroboration and augmentation by people, so it is machine-informed and human-confirmed rather than human. 'What kinds of integrations' is derivable from data already held - client-library dependencies, API surface, compose/config - incomplete but useful. 'What are similar repos' needs analysis and design work before anything is built. Recommendation 6 splits the same way. Nothing is built; this records the direction so the next person does not treat the three as one bucket. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- .../docs/coverage-audit-2026-09-11.md | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/resource-explorer/docs/coverage-audit-2026-09-11.md b/packages/resource-explorer/docs/coverage-audit-2026-09-11.md index e9fe48904..57d89e12c 100644 --- a/packages/resource-explorer/docs/coverage-audit-2026-09-11.md +++ b/packages/resource-explorer/docs/coverage-audit-2026-09-11.md @@ -54,12 +54,17 @@ gap: | kind | n | questions | reading | |---|---|---|---| | `human`, Analysis/Enrichment | 5 | monitoring fit, security fit, infrastructure fit, skills, governance fit | **by design** — rule 17: Enrichment is served by `context.py`, not analyses | -| `human`, Analysis | 2 | *do we already support these dependencies?* · *do we know the cost to run it?* | human by nature of the question; a machine could *inform* but not answer | -| `partial`, Analysis | 1 | *what kinds of integrations does it support?* | partially covered; not enumerated here | -| **`gap`, Analysis** | **3** | ***what are similar repos, how does this differ?* · *what is the upgrade process?* · *for AI/ML assets, what licensing or usage constraints apply?*** | **nothing exists** | +| `human`, Analysis | 1 | *do we know the cost to run it?* | human by nature of the question | +| `human`, Analysis | 1 | *do we already support these dependencies?* | **Decision (project owner, 2026-09-11):** Egeria may hold a starting point — cross-reference `project_dependencies` against the technologies and assets already catalogued there — "that would still need corroboration and augmentation by people". A machine-informed, human-confirmed answer: candidate to move from `human` to `mixed` | +| `partial`, Analysis | 1 | *what kinds of integrations does it support?* | **Decision (project owner, 2026-09-11):** "there are a number of things we could do to try to derive that — may not be complete but still useful". Signals already collected: client-library dependencies (`kafka-python`, `boto3`, `psycopg2`…), the API surface, compose/config files. Derivable, incomplete, worth having | +| `gap`, Analysis | 1 | *what are similar repos, how does this differ?* | **Decision (project owner, 2026-09-11):** "we have to do some analysis and design work" first. Not a derivation from anything collected today | +| `gap`, Analysis | 2 | *what is the upgrade process?* · *for AI/ML assets, what licensing or usage constraints apply?* | nothing exists; not yet discussed | So the honest count of questions with no survey or analytic is **three**, with -one more partially covered. +one more partially covered — but as of 2026-09-11 they are three different +shapes, not one bucket: one has an Egeria-backed starting point, one is +derivable-but-incomplete from data already held, and one needs design work +before anything is built. ## 3. Coverage per stage @@ -177,11 +182,22 @@ third of the machine-answerable catalogued surface. 4. **Own the four unowned steps** under a Scouting-tier profile analysis so their runs attribute. 5. **Retire `repo_profile_refresh`** (dead) and **decide `security_summary`**. -6. **Only then consider new analyses**, and only for the three `gap` - questions. *Upgrade process* and *AI/ML licensing* are plausibly derivable - from data already collected (release notes; license and model-card files). - *Similar repos* is a different kind of problem and should not be built on - the strength of one question. +6. **Only then consider new analyses — and the gap questions are now three + different kinds of work, per the owner's 2026-09-11 directions:** + - *Do we already support these dependencies?* — an **Egeria + cross-reference**: `project_dependencies` against the technologies and + assets Egeria already catalogues, presented as a starting point a person + corroborates and augments, never as the answer. Cheapest of the three; RE + already publishes to Egeria and already holds the dependency rows. + - *What kinds of integrations does it support?* — a **derivation from data + already collected**: client-library dependencies, API surface, + compose/config. Will be incomplete and should say so per finding (the + `partial` vocabulary already exists for it), but useful. + - *What are similar repos?* — **analysis and design work first**. Not a + derivation; do not build it on the strength of one question. + - *Upgrade process* and *AI/ML licensing* are plausibly derivable from data + already collected (release notes; license and model-card files) but have + not been discussed and are not scheduled. 7. **Look at `repo_conventions`' seven checks** for independence — not to split it, but to know whether one broken check takes seven answers with it. From 8a54b65e72eac51fe165f7bd08f996969f26fcc4 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Fri, 11 Sep 2026 21:40:31 -0500 Subject: [PATCH 15/37] Promotion from a member list: the selection becomes a thing someone acts on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A member list is the first place in the product where a person looks at THINGS rather than NUMBERS, and things are what you act on. "18" is not a work item; "sixteen of these are high" is. The list was a selection; the action was not on it. Now it is. The filters are the selection. A facet — a severity, a package — selects its members in one click; hand-picking stays. Facets come from the fields the annotation already stores, and nothing is invented: "no fix" is not a facet because cve_scan does not record fix availability, and a facet the data cannot back would select nothing and look broken. Reported rather than faked. Three acts, and they are different. Add to work list is "I will deal with this"; raise RFA is "someone must"; note in journal is "worth knowing" — no obligation attached, so the likeliest used, and cheap now the journal exists. One provenance line, composed on the server, travels with all three: "16 of 19 advisories · high · from cve_scan, run 2026-09-11: GHSA-…". The selection is a snapshot of names, never a query — a work item that changes what it refers to when the scan re-runs is unusable; five next time is a new observation against the same item. The name proposes itself from the facet and the count — "egeria-workspaces — 16 advisories, high" — and is editable before saving. Signed-in only, for all three: an item needs someone to have made it. Deliberately absent: "ignore" / "accept risk". That is a disposition on the finding — a judgement with an author and a date — and belongs to the perishable-field machinery, not a toolbar. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- .../resource_explorer/members.py | 43 +++++++ .../resource_explorer/web/routes/projects.py | 70 ++++++++++- .../resource_explorer/web/static/next/app.js | 109 +++++++++++++++++- .../web/static/next/tailwind-next.css | 2 +- .../resource_explorer/web/static/re-api.js | 8 ++ .../resource-explorer/tests/test_promotion.py | 92 +++++++++++++++ 6 files changed, 320 insertions(+), 4 deletions(-) create mode 100644 packages/resource-explorer/tests/test_promotion.py diff --git a/packages/resource-explorer/resource_explorer/members.py b/packages/resource-explorer/resource_explorer/members.py index 418c158d5..a0f26d9ef 100644 --- a/packages/resource-explorer/resource_explorer/members.py +++ b/packages/resource-explorer/resource_explorer/members.py @@ -236,3 +236,46 @@ def children_for(registry: ProjectRegistry, slug: str, analysis_id: str, key: st if key.startswith("file:") and analysis_id in ("api_structure", "code_symbol_extraction"): return _symbols_in_file(registry, slug, key[5:], scope, limit) return [] + + +# ── Promotion: a selection becomes a thing someone acts on ────────────────── +# +# A member list is the first place in the product where a person looks at +# THINGS rather than NUMBERS, and things are what you act on. "18" is not a +# work item; "three of these advisories have no fix" is. Three acts, and +# they are different: ADD TO WORK LIST is "I will deal with this"; RAISE RFA +# is "someone must"; NOTE IN JOURNAL is "this is worth knowing about the +# resource" — the one with no obligation attached, and the likeliest used. +# +# A promoted selection keeps its PROVENANCE and does not keep its +# MEMBERSHIP: the analysis, the run date and the members as they were, so +# the item reads "3 of 18 advisories · high · from cve_scan, run 2026-09-03". +# Never a live query. A work item that silently changes what it refers to +# when the scan re-runs is unusable; five next time is a new observation +# against the same item, not a mutation of it. +# +# Deliberately absent: "ignore" / "accept risk". That is a disposition on +# the finding — a judgement with an author and a date — and belongs to the +# perishable-field machinery, not a list's toolbar. + +def provenance_line(*, analysis_id: str, run_at: str, total: int, members: list[str], + facet: str = "", metric: str = "") -> str: + """The sentence every promotion carries. Members are listed by name so the + item stays legible after the scan moves on.""" + n = len(members) + what = metric.replace("_", " ") if metric else "members" + head = f"{n} of {total} {what}" if total and n != total else f"{n} {what}" + if facet: + head += f" · {facet}" + when = f", run {run_at[:10]}" if run_at else "" + names = ", ".join(members[:12]) + (f", and {n - 12} more" if n > 12 else "") + return f"{head} · from {analysis_id}{when}: {names}" + + +def proposed_name(display_name: str, *, total: int, members: list[str], facet: str = "", + metric: str = "") -> str: + """'egeria-workspaces — 3 advisories, high'. Editable before saving.""" + what = (metric.replace("_", " ") if metric else "members") + n = len(members) + core = f"{n} {what}" if n != 1 else f"1 {what.rstrip('s') or what}" + return f"{display_name} — {core}" + (f", {facet}" if facet else "") diff --git a/packages/resource-explorer/resource_explorer/web/routes/projects.py b/packages/resource-explorer/resource_explorer/web/routes/projects.py index 3f83eaeb7..1d4132fbc 100644 --- a/packages/resource-explorer/resource_explorer/web/routes/projects.py +++ b/packages/resource-explorer/resource_explorer/web/routes/projects.py @@ -5,8 +5,8 @@ import json import logging -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field log = logging.getLogger(__name__) router = APIRouter() @@ -1444,3 +1444,69 @@ async def get_member_children(slug: str, analysis_id: str, key: str, scope: str raise HTTPException(status_code=404, detail=f"Project '{slug}' not found") rows = await asyncio.to_thread(lambda: children_for(registry, slug, analysis_id, key, scope=scope, limit=min(max(limit, 1), 1000))) return {"key": key, "members": rows} + + +class PromoteSelection(BaseModel): + """A selection from a member list, with its provenance. `members` are the + names as they were when selected — a snapshot, never a query.""" + action: str # work_list | rfa | journal + metric: str = "" + members: list[str] = Field(default_factory=list) + total: int = 0 + facet: str = "" + run_at: str = "" + name: str = "" # the work item's name; proposed by the client, editable + suggest_to: list[str] = Field(default_factory=list) # journal only + + +@router.post("/{slug}/members/{analysis_id}/promote") +def promote_members(slug: str, analysis_id: str, body: PromoteSelection, request: Request) -> dict: + """Promote a member-list selection: to a work list (I will deal with + this), an RFA (someone must), or the journal (worth knowing). One + provenance line, composed here, travels with all three. See the + promotion note in resource_explorer/members.py. + + Signed-in only: a work item, a request for action and a journal entry + all need someone to have made them. + """ + from resource_explorer.activity_logger import log_rfa + from resource_explorer.auth import get_current_user + from resource_explorer.journal import Journal + from resource_explorer.members import proposed_name, provenance_line + from resource_explorer.registry import ProjectRegistry + from resource_explorer.work_lists import WorkLists + + user = get_current_user(request) + author = (user or {}).get("user_id") or (user or {}).get("sub") or (user or {}).get("username") or "" + if not author: + raise HTTPException(status_code=401, detail="Sign in to promote a selection — it needs someone to have made it.") + if body.action not in ("work_list", "rfa", "journal"): + raise HTTPException(status_code=422, detail="action must be work_list, rfa or journal") + if not body.members: + raise HTTPException(status_code=422, detail="nothing is selected") + + registry = ProjectRegistry() + project = registry.get(slug) + if not project: + raise HTTPException(status_code=404, detail=f"Project '{slug}' not found") + + line = provenance_line(analysis_id=analysis_id, run_at=body.run_at, total=body.total, + members=body.members, facet=body.facet, metric=body.metric) + name = body.name.strip() or proposed_name(project.display_name or slug, total=body.total, + members=body.members, facet=body.facet, metric=body.metric) + + if body.action == "work_list": + wl = WorkLists(registry).create(name, [slug], entity_type="repo", created_by=author, + derived_from=f"members:{analysis_id}", rationale=line, + description=f"Promoted from the {analysis_id} member list.") + return {"action": "work_list", "name": name, "provenance": line, "work_list": wl.get("slug") if wl else None} + + if body.action == "rfa": + rfa_id = log_rfa(registry, "repo", slug, project.display_name or slug, "open", name, detail=line, + analysis_name=analysis_id, + items=[{"kind": "member", "analysis_id": analysis_id, "name": m} for m in body.members[:50]]) + return {"action": "rfa", "name": name, "provenance": line, "rfa": rfa_id} + + entry = Journal(registry).write("repo", slug, author=author, body=f"{name}. {line}", suggest_to=body.suggest_to) + return {"action": "journal", "name": name, "provenance": line, "journal": entry.get("id"), + "work_lists": entry.get("work_lists", [])} diff --git a/packages/resource-explorer/resource_explorer/web/static/next/app.js b/packages/resource-explorer/resource_explorer/web/static/next/app.js index 2f3e61f8f..215412078 100644 --- a/packages/resource-explorer/resource_explorer/web/static/next/app.js +++ b/packages/resource-explorer/resource_explorer/web/static/next/app.js @@ -47,6 +47,7 @@ import { getBulkFacts, getMemberChildren, getMembers, + promoteMembers, getQuestions, getScoutingOverview, listActivity, @@ -3770,6 +3771,108 @@ function memberScope() { return currentPurposes().includes('Maintain') ? 'all' : 'public'; } +/* ── Promotion: the selection becomes a thing someone acts on ──────────── + * + * A member list is the first place in the product where a person looks at + * THINGS rather than NUMBERS, and things are what you act on. "18" is not a + * work item; "three of these have no fix" is. + * + * The filters ARE the selection. "Everything matching the thing I noticed" + * should not need a click per row: a facet — a severity, a package — selects + * its members in one. Hand-picking stays. Facets come from the fields the + * annotation already stores; nothing is invented, which is also why "no + * fix" is not one here — cve_scan does not record fix availability, and a + * facet the data cannot back would select nothing and look broken. + * + * Three acts, and they are different: add to work list (I will deal with + * this), raise RFA (someone must), note in journal (worth knowing — no + * obligation, so the likeliest used). One provenance line, composed on the + * server, travels with all three. The selection is a SNAPSHOT of names, + * never a query: a work item that changes what it refers to when the scan + * re-runs is unusable. + * + * No "ignore" / "accept risk" here. That is a disposition on the finding — a + * judgement with an author and a date — and belongs to the perishable-field + * machinery, not a toolbar. + */ +function facetsHtml(groups) { + const leaf = groups.flatMap((g) => g.members.filter((m) => !m.children_key).map((m) => ({ ...m, group: g.name }))); + if (!leaf.length) return ''; + const byDetail = new Map(); + for (const m of leaf) if (m.detail) byDetail.set(m.detail, (byDetail.get(m.detail) || 0) + 1); + const detailFacets = [...byDetail.entries()].filter(([, n]) => n < leaf.length).sort((a, b) => b[1] - a[1]).slice(0, 6); + const groupFacets = groups.filter((g) => g.members.some((m) => !m.children_key) && groups.length > 1).slice(0, 8); + if (!detailFacets.length && !groupFacets.length) return ''; + return `
    + select + ${detailFacets.map(([d, n]) => ``).join('')} + ${groupFacets.map((g) => ``).join('')} + + +
    `; +} + +function wireSelection(out, { slug, analysisId, metric, data }) { + const picks = () => [...out.querySelectorAll('[data-pick]:checked')]; + const footer = out.querySelector('#member-selection'); + let facet = ''; + const project = state.projects.find((x) => x.slug === slug); + const total = data.total || 0; + const runAt = state.enrichmentFacts?.[analysisId]?.last_run_at || data.run_at || ''; + + const setFacet = (pred, label) => { + out.querySelectorAll('[data-pick]').forEach((c) => { c.checked = pred(c); }); + facet = label; + render(); + }; + out.querySelector('[data-facet-all]')?.addEventListener('click', () => setFacet(() => true, '')); + out.querySelector('[data-facet-none]')?.addEventListener('click', () => setFacet(() => false, '')); + out.querySelectorAll('[data-facet-detail]').forEach((b) => b.addEventListener('click', () => + setFacet((c) => c.dataset.detail === b.dataset.facetDetail, b.dataset.facetDetail))); + out.querySelectorAll('[data-facet-group]').forEach((b) => b.addEventListener('click', () => + setFacet((c) => c.dataset.group === b.dataset.facetGroup, b.dataset.facetGroup.split(' ')[0]))); + out.querySelectorAll('[data-pick]').forEach((c) => c.addEventListener('change', () => { facet = ''; render(); })); + + function proposed(n) { + const what = (metric || data.metric || 'members').replace(/_/g, ' '); + return `${project?.display_name || slug} — ${n} ${what}${facet ? `, ${facet}` : ''}`; + } + function render() { + const sel = picks(); + if (!sel.length) { footer.hidden = true; footer.innerHTML = ''; return; } + footer.hidden = false; + const keep = footer.querySelector('#promote-name')?.value; + footer.innerHTML = ` +
    ${sel.length} selected${facet ? ` · ${esc(facet)}` : ''} + · from ${esc(analysisId)}${runAt ? ` · ${esc(ago(runAt))}` : ''} · a snapshot, not a query
    + +
    + + + + +
    `; + footer.querySelectorAll('[data-promote]').forEach((b) => b.addEventListener('click', async () => { + const status = footer.querySelector('#promote-status'); + const members = picks().map((c) => c.dataset.pick); + b.disabled = true; status.textContent = '…'; + try { + const out2 = await promoteMembers(slug, analysisId, { + action: b.dataset.promote, metric: metric || data.metric || '', members, total, facet, runAt, + name: footer.querySelector('#promote-name').value.trim(), + }); + // Say where it went, not "sent". + const where = out2.work_list ? `work list ${out2.work_list}` : out2.rfa ? `RFA ${String(out2.rfa).slice(0, 8)}` : 'the journal'; + status.innerHTML = `→ ${esc(where)}`; + } catch (err) { + b.disabled = false; + status.innerHTML = `${esc(err.status === 401 ? 'sign in to promote' : err.message)}`; + } + })); + } +} + async function openMembers({ slug, analysisId, metric = '', title = '' }) { const out = $('rail-evidence'); if (!out) return; @@ -3799,11 +3902,13 @@ async function openMembers({ slug, analysisId, metric = '', title = '' }) { ${data.scope_honoured ? '' : `· scope not applicable to this set`} ${data.note ? `
    ${esc(data.note)}
    ` : ''} + ${facetsHtml(groups)}
    ${groups.map((g, gi) => `
    ${g.count} · ${esc(g.name)}
      ${g.members.map((m) => `
    • + ${m.children_key ? '' : ``} ${m.children_key ? ` ${m.count ?? ''}` @@ -3816,9 +3921,11 @@ async function openMembers({ slug, analysisId, metric = '', title = '' }) {
    ${shown < data.total && !groups.some((g) => g.truncated) ? `
    ${shown} of ${data.total} listed; the rest are nested under what is shown
    ` : ''} -
    read from ${esc(data.source)}
    `; +
    read from ${esc(data.source)}
    + `; out.querySelector('[data-act="close-members"]')?.addEventListener('click', () => { out.innerHTML = ''; }); + wireSelection(out, { slug, analysisId, metric, data }); out.querySelector('[data-act="member-history"]')?.addEventListener('click', () => openMeasurementDetail({ slug, analysisId, title: title || analysisId, metric, })); diff --git a/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css b/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css index 5cb2f7058..55d4ed7b8 100644 --- a/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css +++ b/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SF Mono,Menlo,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.z-40{z-index:40}.z-50{z-index:50}.z-\[90\]{z-index:90}.m-0{margin:0}.my-s3{margin-top:13.8px;margin-bottom:13.8px}.-ml-\[22px\]{margin-left:-22px}.mb-\[2px\]{margin-bottom:2px}.mb-\[3px\]{margin-bottom:3px}.mb-\[4px\]{margin-bottom:4px}.mb-\[6px\]{margin-bottom:6px}.mb-\[7px\]{margin-bottom:7px}.mb-s1{margin-bottom:4.6px}.mb-s2{margin-bottom:9.2px}.mb-s3{margin-bottom:13.8px}.mb-s4{margin-bottom:18.4px}.ml-\[22px\]{margin-left:22px}.ml-auto{margin-left:auto}.ml-s2{margin-left:9.2px}.ml-s3{margin-left:13.8px}.mr-1{margin-right:.25rem}.mr-s3{margin-right:13.8px}.mt-\[10px\]{margin-top:10px}.mt-\[2px\]{margin-top:2px}.mt-\[3px\]{margin-top:3px}.mt-\[4px\]{margin-top:4px}.mt-\[5px\]{margin-top:5px}.mt-\[6px\]{margin-top:6px}.mt-\[6vh\]{margin-top:6vh}.mt-\[7px\]{margin-top:7px}.mt-s1{margin-top:4.6px}.mt-s2{margin-top:9.2px}.mt-s3{margin-top:13.8px}.mt-s4{margin-top:18.4px}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.\!grid{display:grid!important}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-\[14px\]{height:14px}.h-\[9px\]{height:9px}.h-full{height:100%}.h-px{height:1px}.min-h-0{min-height:0}.min-h-\[320px\]{min-height:320px}.w-\[12ch\]{width:12ch}.w-\[13px\]{width:13px}.w-\[160px\]{width:160px}.w-\[16px\]{width:16px}.w-\[18px\]{width:18px}.w-\[22px\]{width:22px}.w-\[26px\]{width:26px}.w-\[34px\]{width:34px}.w-\[42\%\]{width:42%}.w-\[min\(380px\2c 92vw\)\]{width:min(380px,92vw)}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[60ch\]{max-width:60ch}.max-w-\[640px\]{max-width:640px}.max-w-\[70ch\]{max-width:70ch}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize-y{resize:vertical}.resize{resize:both}.scroll-mt-\[8px\]{scroll-margin-top:8px}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.grid-cols-\[130px_1fr\]{grid-template-columns:130px 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.items-start{align-items:flex-start}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-\[1px\]{gap:1px}.gap-\[2px\]{gap:2px}.gap-\[4px\]{gap:4px}.gap-\[5px\]{gap:5px}.gap-\[6px\]{gap:6px}.gap-\[9px\]{gap:9px}.gap-s1{gap:4.6px}.gap-s2{gap:9.2px}.gap-s3{gap:13.8px}.gap-s4{gap:18.4px}.gap-x-s3{-moz-column-gap:13.8px;column-gap:13.8px}.gap-y-\[2px\]{row-gap:2px}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-\[2px\]{border-radius:2px}.rounded-pill{border-radius:11px}.rounded-sm{border-radius:2px}.border{border-width:1px}.border-0{border-width:0}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l{border-left-width:1px}.border-l-2{border-left-width:2px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-accent{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.border-accent-deep{--tw-border-opacity:1;border-color:rgb(90 59 10/var(--tw-border-opacity,1))}.border-chrome-line{--tw-border-opacity:1;border-color:rgb(68 65 65/var(--tw-border-opacity,1))}.border-chrome-line-soft{--tw-border-opacity:1;border-color:rgb(45 43 43/var(--tw-border-opacity,1))}.border-chrome-muted{--tw-border-opacity:1;border-color:rgb(155 151 151/var(--tw-border-opacity,1))}.border-rule{border-color:rgba(32,31,29,.16)}.border-rule-strong{--tw-border-opacity:1;border-color:rgb(142 138 138/var(--tw-border-opacity,1))}.border-state-gap{--tw-border-opacity:1;border-color:rgb(91 74 156/var(--tw-border-opacity,1))}.border-state-warn{--tw-border-opacity:1;border-color:rgb(156 66 18/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.bg-\[rgba\(168\2c 113\2c 42\2c \.07\)\]{background-color:rgba(168,113,42,.07)}.bg-\[rgba\(32\2c 31\2c 29\2c \.03\)\]{background-color:rgba(32,31,29,.03)}.bg-black\/40{background-color:rgba(0,0,0,.4)}.bg-chrome{--tw-bg-opacity:1;background-color:rgb(27 26 25/var(--tw-bg-opacity,1))}.bg-chrome-alt{--tw-bg-opacity:1;background-color:rgb(32 31 29/var(--tw-bg-opacity,1))}.bg-chrome-surface{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.bg-paper{--tw-bg-opacity:1;background-color:rgb(243 242 242/var(--tw-bg-opacity,1))}.bg-paper-surface{--tw-bg-opacity:1;background-color:rgb(234 233 233/var(--tw-bg-opacity,1))}.bg-rule{background-color:rgba(32,31,29,.16)}.bg-transparent{background-color:transparent}.p-0{padding:0}.p-\[6px\]{padding:6px}.p-s2{padding:9.2px}.p-s3{padding:13.8px}.p-s4{padding:18.4px}.px-0{padding-left:0;padding-right:0}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-\[10px\]{padding-left:10px;padding-right:10px}.px-\[1px\]{padding-left:1px;padding-right:1px}.px-\[4px\]{padding-left:4px;padding-right:4px}.px-\[5px\]{padding-left:5px;padding-right:5px}.px-\[6px\]{padding-left:6px;padding-right:6px}.px-\[9px\]{padding-left:9px;padding-right:9px}.px-s2{padding-left:9.2px;padding-right:9.2px}.px-s3{padding-left:13.8px;padding-right:13.8px}.py-\[10px\]{padding-top:10px;padding-bottom:10px}.py-\[1px\]{padding-top:1px;padding-bottom:1px}.py-\[2px\]{padding-top:2px;padding-bottom:2px}.py-\[3px\]{padding-top:3px;padding-bottom:3px}.py-\[4px\]{padding-top:4px;padding-bottom:4px}.py-\[5px\]{padding-top:5px;padding-bottom:5px}.py-\[6px\]{padding-top:6px;padding-bottom:6px}.py-\[7px\]{padding-top:7px;padding-bottom:7px}.py-\[9px\]{padding-top:9px;padding-bottom:9px}.py-s2{padding-top:9.2px;padding-bottom:9.2px}.py-s3{padding-top:13.8px;padding-bottom:13.8px}.py-s4{padding-top:18.4px;padding-bottom:18.4px}.pb-\[1px\]{padding-bottom:1px}.pb-\[2px\]{padding-bottom:2px}.pb-\[4px\]{padding-bottom:4px}.pb-s1{padding-bottom:4.6px}.pb-s2{padding-bottom:9.2px}.pb-s3{padding-bottom:13.8px}.pl-0{padding-left:0}.pl-\[20px\]{padding-left:20px}.pl-\[22px\]{padding-left:22px}.pl-s2{padding-left:9.2px}.pl-s3{padding-left:13.8px}.pr-s3{padding-right:13.8px}.pt-\[9px\]{padding-top:9px}.pt-s1{padding-top:4.6px}.pt-s2{padding-top:9.2px}.pt-s3{padding-top:13.8px}.text-left{text-align:left}.text-right{text-align:right}.align-bottom{vertical-align:bottom}.align-\[-2px\]{vertical-align:-2px}.font-body{font-family:Lora,Georgia,serif}.font-diagram{font-family:ui-monospace,SF Mono,Menlo,monospace}.font-heading{font-family:Cormorant Garamond,Georgia,serif}.font-mono{font-family:ui-monospace,SF Mono,Menlo,monospace}.text-\[1\.1rem\]{font-size:1.1rem}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.text-\[17px\]{font-size:17px}.text-answer{font-size:.9375rem;line-height:1.55}.text-brand{font-size:1.1875rem;line-height:1.2}.text-caps{font-size:.75rem;line-height:1.3}.text-caveat{font-size:.9375rem;line-height:1.55}.text-chip{font-size:.8125rem;line-height:1.3}.text-intent{font-size:.875rem;line-height:1.2}.text-name{font-size:1.625rem;line-height:1.15}.text-provenance{font-size:.8125rem;line-height:1.45}.text-question{font-size:1.0625rem;line-height:1.25}.text-resource{font-size:.8125rem;line-height:1.35}.text-subtab{font-size:.875rem;line-height:1.2}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.leading-\[1\.95\]{line-height:1.95}.leading-none{line-height:1}.tracking-caps{letter-spacing:.09em}.tracking-normal{letter-spacing:0}.text-accent{--tw-text-opacity:1;color:rgb(182 130 53/var(--tw-text-opacity,1))}.text-accent-ink{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.text-accent-on-dark{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.text-chrome-ink{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.text-chrome-muted{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.text-ink{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.text-ink-muted{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.text-rule-strong{--tw-text-opacity:1;color:rgb(142 138 138/var(--tw-text-opacity,1))}.text-state-gap{--tw-text-opacity:1;color:rgb(91 74 156/var(--tw-text-opacity,1))}.text-state-gap-on-dark{--tw-text-opacity:1;color:rgb(169 154 224/var(--tw-text-opacity,1))}.text-state-ok{--tw-text-opacity:1;color:rgb(29 107 63/var(--tw-text-opacity,1))}.text-state-ok-on-dark{--tw-text-opacity:1;color:rgb(116 198 141/var(--tw-text-opacity,1))}.text-state-warn{--tw-text-opacity:1;color:rgb(156 66 18/var(--tw-text-opacity,1))}.text-state-warn-on-dark{--tw-text-opacity:1;color:rgb(242 162 106/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-dotted{text-decoration-style:dotted}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-100{opacity:1}.shadow-lg{--tw-shadow:0 12px 32px rgba(45,43,43,.22);--tw-shadow-colored:0 12px 32px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.placeholder\:text-chrome-muted::-moz-placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.placeholder\:text-chrome-muted::placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.placeholder\:text-ink-muted::-moz-placeholder{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.placeholder\:text-ink-muted::placeholder{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.last\:border-0:last-child{border-width:0}.hover\:border-accent:hover{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.hover\:bg-\[rgba\(32\2c 31\2c 29\2c \.05\)\]:hover{background-color:rgba(32,31,29,.05)}.hover\:bg-accent-tint:hover{--tw-bg-opacity:1;background-color:rgb(255 243 228/var(--tw-bg-opacity,1))}.hover\:bg-chrome-surface:hover{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.hover\:text-accent-ink:hover{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.hover\:text-accent-on-dark:hover{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.hover\:text-chrome-ink:hover{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.hover\:text-ink:hover{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.focus-visible\:text-chrome-ink:focus-visible{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SF Mono,Menlo,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.z-40{z-index:40}.z-50{z-index:50}.z-\[90\]{z-index:90}.m-0{margin:0}.my-s3{margin-top:13.8px;margin-bottom:13.8px}.-ml-\[22px\]{margin-left:-22px}.mb-\[2px\]{margin-bottom:2px}.mb-\[3px\]{margin-bottom:3px}.mb-\[4px\]{margin-bottom:4px}.mb-\[6px\]{margin-bottom:6px}.mb-\[7px\]{margin-bottom:7px}.mb-s1{margin-bottom:4.6px}.mb-s2{margin-bottom:9.2px}.mb-s3{margin-bottom:13.8px}.mb-s4{margin-bottom:18.4px}.ml-\[22px\]{margin-left:22px}.ml-auto{margin-left:auto}.ml-s2{margin-left:9.2px}.ml-s3{margin-left:13.8px}.mr-1{margin-right:.25rem}.mr-s3{margin-right:13.8px}.mt-\[10px\]{margin-top:10px}.mt-\[2px\]{margin-top:2px}.mt-\[3px\]{margin-top:3px}.mt-\[4px\]{margin-top:4px}.mt-\[5px\]{margin-top:5px}.mt-\[6px\]{margin-top:6px}.mt-\[6vh\]{margin-top:6vh}.mt-\[7px\]{margin-top:7px}.mt-s1{margin-top:4.6px}.mt-s2{margin-top:9.2px}.mt-s3{margin-top:13.8px}.mt-s4{margin-top:18.4px}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.\!grid{display:grid!important}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-\[14px\]{height:14px}.h-\[9px\]{height:9px}.h-full{height:100%}.h-px{height:1px}.min-h-0{min-height:0}.min-h-\[320px\]{min-height:320px}.w-\[12ch\]{width:12ch}.w-\[13px\]{width:13px}.w-\[160px\]{width:160px}.w-\[16px\]{width:16px}.w-\[18px\]{width:18px}.w-\[22px\]{width:22px}.w-\[26px\]{width:26px}.w-\[34px\]{width:34px}.w-\[42\%\]{width:42%}.w-\[min\(380px\2c 92vw\)\]{width:min(380px,92vw)}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[60ch\]{max-width:60ch}.max-w-\[640px\]{max-width:640px}.max-w-\[70ch\]{max-width:70ch}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize-y{resize:vertical}.resize{resize:both}.scroll-mt-\[8px\]{scroll-margin-top:8px}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.grid-cols-\[130px_1fr\]{grid-template-columns:130px 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.items-start{align-items:flex-start}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-\[1px\]{gap:1px}.gap-\[2px\]{gap:2px}.gap-\[4px\]{gap:4px}.gap-\[5px\]{gap:5px}.gap-\[6px\]{gap:6px}.gap-\[9px\]{gap:9px}.gap-s1{gap:4.6px}.gap-s2{gap:9.2px}.gap-s3{gap:13.8px}.gap-s4{gap:18.4px}.gap-x-s2{-moz-column-gap:9.2px;column-gap:9.2px}.gap-x-s3{-moz-column-gap:13.8px;column-gap:13.8px}.gap-y-\[2px\]{row-gap:2px}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-\[2px\]{border-radius:2px}.rounded-pill{border-radius:11px}.rounded-sm{border-radius:2px}.border{border-width:1px}.border-0{border-width:0}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l{border-left-width:1px}.border-l-2{border-left-width:2px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-accent{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.border-accent-deep{--tw-border-opacity:1;border-color:rgb(90 59 10/var(--tw-border-opacity,1))}.border-chrome-line{--tw-border-opacity:1;border-color:rgb(68 65 65/var(--tw-border-opacity,1))}.border-chrome-line-soft{--tw-border-opacity:1;border-color:rgb(45 43 43/var(--tw-border-opacity,1))}.border-chrome-muted{--tw-border-opacity:1;border-color:rgb(155 151 151/var(--tw-border-opacity,1))}.border-rule{border-color:rgba(32,31,29,.16)}.border-rule-strong{--tw-border-opacity:1;border-color:rgb(142 138 138/var(--tw-border-opacity,1))}.border-state-gap{--tw-border-opacity:1;border-color:rgb(91 74 156/var(--tw-border-opacity,1))}.border-state-warn{--tw-border-opacity:1;border-color:rgb(156 66 18/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.bg-\[rgba\(168\2c 113\2c 42\2c \.07\)\]{background-color:rgba(168,113,42,.07)}.bg-\[rgba\(32\2c 31\2c 29\2c \.03\)\]{background-color:rgba(32,31,29,.03)}.bg-black\/40{background-color:rgba(0,0,0,.4)}.bg-chrome{--tw-bg-opacity:1;background-color:rgb(27 26 25/var(--tw-bg-opacity,1))}.bg-chrome-alt{--tw-bg-opacity:1;background-color:rgb(32 31 29/var(--tw-bg-opacity,1))}.bg-chrome-surface{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.bg-paper{--tw-bg-opacity:1;background-color:rgb(243 242 242/var(--tw-bg-opacity,1))}.bg-paper-surface{--tw-bg-opacity:1;background-color:rgb(234 233 233/var(--tw-bg-opacity,1))}.bg-rule{background-color:rgba(32,31,29,.16)}.bg-transparent{background-color:transparent}.p-0{padding:0}.p-\[6px\]{padding:6px}.p-s2{padding:9.2px}.p-s3{padding:13.8px}.p-s4{padding:18.4px}.px-0{padding-left:0;padding-right:0}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-\[10px\]{padding-left:10px;padding-right:10px}.px-\[1px\]{padding-left:1px;padding-right:1px}.px-\[4px\]{padding-left:4px;padding-right:4px}.px-\[5px\]{padding-left:5px;padding-right:5px}.px-\[6px\]{padding-left:6px;padding-right:6px}.px-\[9px\]{padding-left:9px;padding-right:9px}.px-s2{padding-left:9.2px;padding-right:9.2px}.px-s3{padding-left:13.8px;padding-right:13.8px}.py-\[10px\]{padding-top:10px;padding-bottom:10px}.py-\[1px\]{padding-top:1px;padding-bottom:1px}.py-\[2px\]{padding-top:2px;padding-bottom:2px}.py-\[3px\]{padding-top:3px;padding-bottom:3px}.py-\[4px\]{padding-top:4px;padding-bottom:4px}.py-\[5px\]{padding-top:5px;padding-bottom:5px}.py-\[6px\]{padding-top:6px;padding-bottom:6px}.py-\[7px\]{padding-top:7px;padding-bottom:7px}.py-\[9px\]{padding-top:9px;padding-bottom:9px}.py-s2{padding-top:9.2px;padding-bottom:9.2px}.py-s3{padding-top:13.8px;padding-bottom:13.8px}.py-s4{padding-top:18.4px;padding-bottom:18.4px}.pb-\[1px\]{padding-bottom:1px}.pb-\[2px\]{padding-bottom:2px}.pb-\[4px\]{padding-bottom:4px}.pb-s1{padding-bottom:4.6px}.pb-s2{padding-bottom:9.2px}.pb-s3{padding-bottom:13.8px}.pl-0{padding-left:0}.pl-\[20px\]{padding-left:20px}.pl-\[22px\]{padding-left:22px}.pl-s2{padding-left:9.2px}.pl-s3{padding-left:13.8px}.pr-s3{padding-right:13.8px}.pt-\[9px\]{padding-top:9px}.pt-s1{padding-top:4.6px}.pt-s2{padding-top:9.2px}.pt-s3{padding-top:13.8px}.text-left{text-align:left}.text-right{text-align:right}.align-bottom{vertical-align:bottom}.align-\[-2px\]{vertical-align:-2px}.font-body{font-family:Lora,Georgia,serif}.font-diagram{font-family:ui-monospace,SF Mono,Menlo,monospace}.font-heading{font-family:Cormorant Garamond,Georgia,serif}.font-mono{font-family:ui-monospace,SF Mono,Menlo,monospace}.text-\[1\.1rem\]{font-size:1.1rem}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.text-\[17px\]{font-size:17px}.text-answer{font-size:.9375rem;line-height:1.55}.text-brand{font-size:1.1875rem;line-height:1.2}.text-caps{font-size:.75rem;line-height:1.3}.text-caveat{font-size:.9375rem;line-height:1.55}.text-chip{font-size:.8125rem;line-height:1.3}.text-intent{font-size:.875rem;line-height:1.2}.text-name{font-size:1.625rem;line-height:1.15}.text-provenance{font-size:.8125rem;line-height:1.45}.text-question{font-size:1.0625rem;line-height:1.25}.text-resource{font-size:.8125rem;line-height:1.35}.text-subtab{font-size:.875rem;line-height:1.2}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.leading-\[1\.95\]{line-height:1.95}.leading-none{line-height:1}.tracking-caps{letter-spacing:.09em}.tracking-normal{letter-spacing:0}.text-accent{--tw-text-opacity:1;color:rgb(182 130 53/var(--tw-text-opacity,1))}.text-accent-ink{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.text-accent-on-dark{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.text-chrome-ink{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.text-chrome-muted{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.text-ink{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.text-ink-muted{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.text-rule-strong{--tw-text-opacity:1;color:rgb(142 138 138/var(--tw-text-opacity,1))}.text-state-gap{--tw-text-opacity:1;color:rgb(91 74 156/var(--tw-text-opacity,1))}.text-state-gap-on-dark{--tw-text-opacity:1;color:rgb(169 154 224/var(--tw-text-opacity,1))}.text-state-ok{--tw-text-opacity:1;color:rgb(29 107 63/var(--tw-text-opacity,1))}.text-state-ok-on-dark{--tw-text-opacity:1;color:rgb(116 198 141/var(--tw-text-opacity,1))}.text-state-warn{--tw-text-opacity:1;color:rgb(156 66 18/var(--tw-text-opacity,1))}.text-state-warn-on-dark{--tw-text-opacity:1;color:rgb(242 162 106/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-dotted{text-decoration-style:dotted}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.accent-accent{accent-color:#b68235}.opacity-100{opacity:1}.shadow-lg{--tw-shadow:0 12px 32px rgba(45,43,43,.22);--tw-shadow-colored:0 12px 32px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.placeholder\:text-chrome-muted::-moz-placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.placeholder\:text-chrome-muted::placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.placeholder\:text-ink-muted::-moz-placeholder{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.placeholder\:text-ink-muted::placeholder{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.last\:border-0:last-child{border-width:0}.hover\:border-accent:hover{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.hover\:bg-\[rgba\(32\2c 31\2c 29\2c \.05\)\]:hover{background-color:rgba(32,31,29,.05)}.hover\:bg-accent-tint:hover{--tw-bg-opacity:1;background-color:rgb(255 243 228/var(--tw-bg-opacity,1))}.hover\:bg-chrome-surface:hover{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.hover\:text-accent-ink:hover{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.hover\:text-accent-on-dark:hover{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.hover\:text-chrome-ink:hover{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.hover\:text-ink:hover{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.focus-visible\:text-chrome-ink:focus-visible{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))} \ No newline at end of file diff --git a/packages/resource-explorer/resource_explorer/web/static/re-api.js b/packages/resource-explorer/resource_explorer/web/static/re-api.js index 5fe0cc0ac..d96ce35b5 100644 --- a/packages/resource-explorer/resource_explorer/web/static/re-api.js +++ b/packages/resource-explorer/resource_explorer/web/static/re-api.js @@ -428,6 +428,14 @@ export const getMembers = (slug, analysisId, { metric = '', scope = 'public', li return get(`/api/projects/${encodeURIComponent(slug)}/members/${encodeURIComponent(analysisId)}?${qs}`); }; +/** Promote a member-list selection. Three acts, one provenance line + * composed on the server: work_list (I will deal with this), rfa (someone + * must), journal (worth knowing). `members` is a snapshot of names, never + * a query. 401 when anonymous. */ +export const promoteMembers = (slug, analysisId, { action, metric = '', members = [], total = 0, facet = '', runAt = '', name = '', suggestTo = [] }) => + post(`/api/projects/${encodeURIComponent(slug)}/members/${encodeURIComponent(analysisId)}/promote`, + { action, metric, members, total, facet, run_at: runAt, name, suggest_to: suggestTo }); + export const getMemberChildren = (slug, analysisId, key, { scope = 'public', limit = 200 } = {}) => get(`/api/projects/${encodeURIComponent(slug)}/members/${encodeURIComponent(analysisId)}/children?${ new URLSearchParams({ key, scope, limit: String(limit) })}`); diff --git a/packages/resource-explorer/tests/test_promotion.py b/packages/resource-explorer/tests/test_promotion.py new file mode 100644 index 000000000..2eb3c7cb7 --- /dev/null +++ b/packages/resource-explorer/tests/test_promotion.py @@ -0,0 +1,92 @@ +"""Promotion from a member list — resource_explorer/members.py helpers and +POST /api/projects/{slug}/members/{analysis_id}/promote. + +A promoted selection keeps its provenance and not its membership: the +analysis, the run date and the members as they were. Three acts, one +provenance line, signed-in only. +""" +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from resource_explorer.journal import Journal +from resource_explorer.members import proposed_name, provenance_line +from resource_explorer.registry import Project, ProjectRegistry +from resource_explorer.work_lists import WorkLists + + +class TestTheLine: + def test_provenance_names_the_run_the_facet_and_the_members(self): + line = provenance_line(analysis_id="cve_scan", run_at="2026-09-03T02:57:00", total=18, + members=["GHSA-1", "GHSA-2", "GHSA-3"], facet="high", metric="advisories") + assert line == "3 of 18 advisories · high · from cve_scan, run 2026-09-03: GHSA-1, GHSA-2, GHSA-3" + + def test_a_long_selection_is_truncated_not_dropped(self): + line = provenance_line(analysis_id="x", run_at="", total=0, members=[f"m{i}" for i in range(15)]) + assert line.endswith("m11, and 3 more") + + def test_the_proposed_name_composes_from_facet_and_count(self): + assert proposed_name("egeria-workspaces", total=18, members=["a", "b", "c"], facet="high", metric="advisories") \ + == "egeria-workspaces — 3 advisories, high" + + +@pytest.fixture +def registry(tmp_path): + r = ProjectRegistry(db_path=str(tmp_path / "t.db")) + r.add(Project(slug="p", display_name="P repo", github_url="https://github.com/x/p", description="")) + return r + + +@pytest.fixture +def client(registry, monkeypatch): + monkeypatch.setattr("resource_explorer.registry.ProjectRegistry.__init__", + lambda self, db_path=None: setattr(self, "__dict__", registry.__dict__) or None) + monkeypatch.setenv("TRELLIS_ANONYMOUS_READ", "true") + monkeypatch.setattr("resource_explorer.auth.get_current_user", lambda request: {"user_id": "peterprofile"}) + from resource_explorer.web.app import app + return TestClient(app) + + +SEL = {"metric": "advisories", "members": ["GHSA-1", "GHSA-2", "GHSA-3"], "total": 18, + "facet": "high", "run_at": "2026-09-03T02:57:00"} + + +class TestThreeActs: + def test_work_list_keeps_provenance_as_rationale_and_the_resource_as_member(self, client, registry): + r = client.post("/api/projects/p/members/cve_scan/promote", json={"action": "work_list", **SEL}) + assert r.status_code == 200, r.text + out = r.json() + wl = WorkLists(registry).get(out["work_list"]) + assert wl["display_name"] == "P repo — 3 advisories, high" + assert wl["derived_from"] == "members:cve_scan" and wl["created_by"] == "peterprofile" + m = next(x for x in wl["members"] if x["entity_slug"] == "p") + assert m["rationale"].startswith("3 of 18 advisories · high · from cve_scan, run 2026-09-03: GHSA-1") + + def test_a_given_name_wins_over_the_proposal(self, client, registry): + r = client.post("/api/projects/p/members/cve_scan/promote", json={"action": "work_list", "name": " fix these ", **SEL}) + assert WorkLists(registry).get(r.json()["work_list"])["display_name"] == "fix these" + + def test_journal_entry_carries_the_line_and_routes_a_suggestion(self, client, registry): + r = client.post("/api/projects/p/members/cve_scan/promote", + json={"action": "journal", "suggest_to": ["Security"], **SEL}) + assert r.status_code == 200, r.text + e = Journal(registry).entries("repo", "p")[0] + assert e["author"] == "peterprofile" and "from cve_scan, run 2026-09-03" in e["body"] + assert r.json()["work_lists"] == [{"target": "Security", "work_list": "suggested-to-security"}] + + def test_rfa_is_raised_with_the_line_as_detail(self, client, registry): + r = client.post("/api/projects/p/members/cve_scan/promote", json={"action": "rfa", **SEL}) + assert r.status_code == 200, r.text + assert r.json()["rfa"] + rfas = client.get("/api/activity/rfas").json() + mine = [x for x in rfas if "3 advisories, high" in (x.get("summary") or x.get("question") or "")] + assert mine, rfas[:2] + + def test_empty_selection_and_bad_action_are_refused(self, client): + assert client.post("/api/projects/p/members/cve_scan/promote", json={"action": "work_list", "members": []}).status_code == 422 + assert client.post("/api/projects/p/members/cve_scan/promote", json={"action": "ignore", **SEL}).status_code == 422 + + def test_anonymous_is_refused(self, client, monkeypatch): + monkeypatch.setattr("resource_explorer.auth.get_current_user", lambda request: None) + assert client.post("/api/projects/p/members/cve_scan/promote", json={"action": "journal", **SEL}).status_code == 401 From 3d8b99b5e7be1217b662fe77c563754ec6228d95 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sat, 12 Sep 2026 06:55:02 -0500 Subject: [PATCH 16/37] =?UTF-8?q?RE=20experiment:=20run=208=20=E2=80=94=20?= =?UTF-8?q?yes/no=20evidence-line=20sentence;=20confounded=20by=20a=20mid-?= =?UTF-8?q?run=20scheduled=20re-survey=20and=20a=20catalog=20rewrite,=20ne?= =?UTF-8?q?gative=20on=20the=20aggregate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branch re/compiler-yes-no-evidence-line (298ff67), run from a worktree. 312 rows, 0 errors, replay 156/156 - but only 2 of 156 compiles share run 7's evidence apart from the new sentence. Two causes, checked: the Automate scheduler's nightly RepoRefreshSurvey ran on docling (23:12) and egeria_python_git (23:45) inside run 8's own window, and the catalog's caveat texts were rewritten on main (63f498e), which moves ranking. kafka changed only the second way. On the three CVE rows the sentence was for, it worked (egeria cites the click advisory, kafka gives value plus caveat, docling states the coverage). On the aggregate: supported claims -0.49 paired, answers_question -0.07, median answer length 146 -> 112 chars, with the loss on non-yes/no questions where the 8B model became terse. Plausibly the sentence priming answer-then-one-line everywhere; not attributable under the confound. Decision: branch not merged. Next: a within-run A/B, two compiled arms answered back-to-back per question in one process, instruction variant as a compile parameter, schedules for the three repos paused for the window. Rubric v4 rides along. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Dan Wolfson --- .../docs/experiments/compiled-vs-rag.md | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/packages/resource-explorer/docs/experiments/compiled-vs-rag.md b/packages/resource-explorer/docs/experiments/compiled-vs-rag.md index 6a6b84dc3..577b5e6b5 100644 --- a/packages/resource-explorer/docs/experiments/compiled-vs-rag.md +++ b/packages/resource-explorer/docs/experiments/compiled-vs-rag.md @@ -80,6 +80,77 @@ Runs judged under different rubrics are never averaged together. ## Runs +### run8-20260912 — "a yes/no answer must carry its evidence line"; confounded, and negative on the aggregate + +One intended variable: the instructions (FULL and SUMMARY rungs) now say a yes/no answer must be +followed by the analysis, the value and any coverage limit or caveat beside it, and that a bare +yes or no is not an answer (branch `re/compiler-yes-no-evidence-line`, 298ff67, run from a +worktree). Same judge (v3), seed and repos. `data/experiments/compiled_vs_rag_run8/results.jsonl`. +312 rows, 0 errors, replayability 156 of 156. + +**Confound, stated first.** Only 2 of 156 compiles have evidence text identical to run 7's apart +from the new sentence; 148 differ in the evidence sections themselves, in all three repos (46–51 +of 52 each). Two causes, both checked rather than assumed (the second by the /next session, from +the activity log, after a findings-timestamp check of mine had missed it): + +- The **Automate scheduler re-surveyed two of the three repos inside run 8's own window**: + `RepoRefreshSurvey` (five steps, rewrites findings and metrics) ran on docling at 23:12 and on + egeria_python_git at 23:45 on 2026-09-11, while run 8 (started about 19:00, finished the next + morning) was answering. That is the `interface_surface` findings present in run 7's text and + absent in run 8's, and documentation_coverage's 1080 → 1188 `.md/.txt` files — a fresh survey + of a repository that had grown, not a data defect. So rows answered before and after those + times describe two states even *within* run 8. +- The catalog's caveat texts and wording were rewritten on `main` (63f498e) between the runs; + through relevance ranking that changes which sections and rungs get packed. kafka, which had no + scheduled run, changed only this way. + +Run 8 therefore carries three variables against run 7 — the sentence, a mid-run re-survey, and a +catalog rewrite. The paired numbers below are reported because they were measured, not because +they attribute anything. Two protocol consequences: the nightly schedules on the experiment repos +must be paused for a run window (or the run must pin a survey run, which the compiler cannot do +today — evidence is keyed by analysis id and surveyed-at, and there is no run id to pin; see +`docs/annotation-identity-check.md`); and the two arms of any comparison have to be answered +back-to-back per question, in one process, so nothing can move between them. + +| metric (v3) | run 8 compiled | run 8 rag | run 7 compiled | run 7 rag | +|---|---:|---:|---:|---:| +| answers_question (0–2) | 1.05 | 0.31 | 1.12 | 0.30 | +| supported_claims (mean) | **1.49** | 0.31 | 1.98 | 0.33 | +| unsupported_claims (mean) | 0.34 | 0.83 | 0.28 | 0.79 | +| misread_claims (mean) | 0.02 | 0.06 | 0.03 | 0.04 | +| cites_evidence | 65% | 4% | 67% | 5% | +| declines | 24% | 59% | 31% | 60% | + +Paired (compiled, run 8 minus run 7): answers_question −0.07 (24 up, 37 down); supported_claims +**−0.49** (31 up, 48 down); unsupported +0.06; cites −0.01; declines −0.07. Median compiled answer +length fell from 146 to 112 characters; bare one-word answers rose from 11 to 14. + +**On the three rows the sentence was for, it worked.** egeria: "Yes, 1 advisory for click ==8.3.1: +PYSEC-2026-2132" (run 7: "Yes", scored 0; now 2). kafka: "no, evidence line: advisories: 0.0 — +caveat: … DECLARED dependencies only …" (2). docling: "Yes, there are no CVE results because none +of the declared dependencies could be queried" — the sentence is right and the "Yes" contradicts +it (1, one unsupported claim, down from a bare wrong "Yes"). Over the 72 yes/no questions the +change is small: answers_question 0.89 → 0.86, supported 0.76 → 0.64, cites 46% → 49%. + +**Where the aggregate went is not the yes/no questions.** Supported claims fell −0.89 on +non-yes/no `analysis` questions and −1.60 on non-yes/no `direct` ones: the Scorecard question's +answer went from fifteen statuses recited (16 supported claims) to "pass (8) and fail (3)"; "is +this worth investigating" from 620 characters to "It is worth investigating further." Two of the +three repos' answers got shorter (egeria 160 → 101 characters median, docling 160 → 120; kafka +116 → 128). A catalog rewrite and an inventory change do not obviously make an 8B model terser +on questions the sentence does not apply to; a sentence that says "give the yes or no and then +the evidence line" plausibly does, by priming answer-then-one-line everywhere. That reading is +consistent with the data and not established by it, because of the confound above. + +**Decision.** The branch is not merged. Run 8 cannot attribute, and the one clean signal it has — +the CVE rows now carry their evidence — is three rows. The next design is the one the protocol +should have used from run 3 on: a **within-run A/B**, two compiled conditions (`compiled` and +`compiled+yesno`) answered back-to-back for each question in one process, with the instruction +variant as a `compile_context` parameter so both compile ids and both texts are on the row, and +the Automate schedules for the three repos paused for the window. Rubric v4 (decline rule +tightened, absence over 0-checked evidence counted as misread) can land in the same run since it +re-scores both arms alike. + ### run7-20260911 — the catalog caveat beside the evidence (branch re/compiler-catalog-caveats, 2c9ca21) Same judge (v3), seed and repos as run 6; the compiler gained one thing: the matched question's From 2ef167de03e8d7e0d8bdfe92448fa883d7e9241c Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sat, 12 Sep 2026 07:11:24 -0500 Subject: [PATCH 17/37] RE tests: the malformed-usage test talks to a file store, not localhost:5025 test_a_malformed_usage_dict_does_not_propagate forced the reachability probe true and left the real MLflow REST client pointed at the default tracking URI. That is a running server on a dev box and nothing on the CI runner, where urllib3's retry backoff ran past the 120s pytest timeout -- every main run of the resource-explorer workflow since 2026-09-11 12:06 failed on it, masking whatever else might fail. The test now stubs get_config with a tmp_path file store, so the bad dict reaches the sink and the assertion (it must not raise) is actually exercised, offline. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- packages/resource-explorer/tests/test_llm_usage.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/resource-explorer/tests/test_llm_usage.py b/packages/resource-explorer/tests/test_llm_usage.py index 463a3c404..6d7a6f386 100644 --- a/packages/resource-explorer/tests/test_llm_usage.py +++ b/packages/resource-explorer/tests/test_llm_usage.py @@ -456,8 +456,19 @@ def _boom(_uri): monkeypatch.setattr(mlflow_tracking, "endpoint_reachable", _boom) mlflow_tracking.log_run_usage("r1", "analysis_run", {"llm_total_tokens": 5}) - def test_a_malformed_usage_dict_does_not_propagate(self, monkeypatch): + def test_a_malformed_usage_dict_does_not_propagate(self, monkeypatch, tmp_path): + """The bad dict has to reach the sink for this to test anything, so + MLflow is pointed at a file store in tmp_path. Forcing the + reachability probe alone left the real REST client talking to + localhost:5025 -- present on a dev box, absent on CI, where urllib3's + retry backoff outran the 120s pytest timeout on every main run from + 2026-09-11 on.""" + from types import SimpleNamespace from resource_explorer.observability import mlflow_tracking + + cfg = SimpleNamespace(observability=SimpleNamespace(mlflow=SimpleNamespace( + enabled=True, tracking_uri=tmp_path.as_uri(), experiment_name="t"))) + monkeypatch.setattr(mlflow_tracking, "get_config", lambda: cfg) monkeypatch.setattr(mlflow_tracking, "endpoint_reachable", lambda _u: True) for bad in ({"llm_models": None}, {"llm_total_tokens": object()}, {"x": [1, 2]}): mlflow_tracking.log_run_usage("r1", "analysis_run", bad) From 06fca78d12e64c46d0ea2a82464f34ba46e0347b Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sat, 12 Sep 2026 07:26:08 -0500 Subject: [PATCH 18/37] CI: run the resource-explorer suite on every branch, not only main and re/** Every change now lands on a branch + PR, so a suite triggered only on main runs only after merge. That is how the MLflow timeout stayed red for 20 consecutive main runs while each branch PR showed mergeable with no test check -- including the branch carrying the fix, which had to be run by workflow_dispatch. Still push-only, per the 2026-09-03 note: a pull_request trigger alongside push doubles the runs. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- .github/workflows/resource-explorer.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/resource-explorer.yml b/.github/workflows/resource-explorer.yml index 8253b5213..120ae3b1b 100644 --- a/.github/workflows/resource-explorer.yml +++ b/.github/workflows/resource-explorer.yml @@ -14,10 +14,14 @@ name: resource-explorer on: push: - # re/** as well as main: this workflow was added on a long-lived re/* - # feature branch, and a workflow that only triggers on main is untested - # YAML until the day it is merged — which is the worst moment to discover - # that a service container does not come up. + # Every branch, not just main and re/**: since 2026-09-10 all work lands + # on a branch + PR (main heads the upstream sync PR), and a suite that + # runs only on main runs only after merge. That is how a test that timed + # out on CI stayed red for 20 consecutive main runs while every branch PR + # showed "mergeable" with no test check at all — the fix/ branch carrying + # the repair could not run it either. (The earlier re/** entry existed for + # the same reason at smaller scale: a workflow that only triggers on main + # is untested YAML until the day it is merged.) # # No separate `pull_request:` trigger, deliberately (removed 2026-09-03): # every commit on an open PR from this repo already matches one of the @@ -28,7 +32,7 @@ on: # checks on the PR merge box. This repo has no forks to cover, so `push:` # alone loses no coverage. If forked PRs are ever accepted, `pull_request:` # needs to come back — for a fork, `push:` never fires at all. - branches: [main, "re/**"] + branches: ["**"] workflow_dispatch: defaults: From 5ef898865361c024828d4372b65e14edb93873bd Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sat, 12 Sep 2026 07:33:08 -0500 Subject: [PATCH 19/37] RE: the review's three fix-firsts -- a NameError, a duplicate id, and a guard that guards Designer review of 2026-09-12, section 1. _local_files_for_paths referenced local_root, unbound in that method: a NameError on any extra_docs_paths directory since #36, with no test on the branch. An extra path lives outside the clone, so vendored-ness is tested against the extra directory itself -- the extra-path PDF walk had the same wrong root and failed open. cli/wizard.py's docs-directory walk now reads the rule too. Two elements shared id="disposition-history" (the header popover and the Disposition pane); once the popover had opened, the pane's trail could never refresh and the no-URL message landed in the popover. The popover's element has its own id and renderDispositionHistory takes a target. The vendored-walk guard is rewritten: AST, per function, whole package, transitive within the module, allowlist keyed by (module, function) that fails when an entry no longer names a walk. The first version was a file-level substring test that could not see a sixth bare walk in a file already reading the rule -- the exact regression it was for. Made to fail three ways on purpose; the outputs are in docs/vendored-guard.md. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- .../resource-explorer/docs/vendored-guard.md | 91 +++++++++ .../resource_explorer/cli/wizard.py | 3 +- .../resource_explorer/ingestion/pipeline.py | 7 +- .../resource_explorer/web/static/next/app.js | 13 +- .../resource-explorer/tests/test_vendored.py | 188 ++++++++++++++++-- 5 files changed, 276 insertions(+), 26 deletions(-) create mode 100644 packages/resource-explorer/docs/vendored-guard.md diff --git a/packages/resource-explorer/docs/vendored-guard.md b/packages/resource-explorer/docs/vendored-guard.md new file mode 100644 index 000000000..a2fa763ca --- /dev/null +++ b/packages/resource-explorer/docs/vendored-guard.md @@ -0,0 +1,91 @@ +# The vendored-walk guard, and the record of it failing + +**2026-09-12.** `tests/test_vendored.py::TestEveryWalkReadsTheRule` +exists because PR #36 fixed the ingestion pipeline's tree walks and missed +the survey step's duplicate of one of them, which put 20,654 vendored +symbols back one survey later. The rule is one function, +`resource_explorer/ingestion/vendored.py`; the guard's job is that no +walk over a repository's tree can be added without consulting it. + +## Why it was rewritten + +The first guard (#39) was two regex literals and a file-level substring +test: any file containing `rglob("*")` or `os.walk(` had to mention +`is_vendored` *somewhere*. Reviewed by the designer on 2026-09-12, it +missed exactly the shape it was built for: + +- a bare walk **appended to a file that already reads the rule** — + `pipeline.py` mentions `is_vendored` five times, so a sixth walk there + was invisible; that is the "duplicated rather than imported" regression + itself; +- `rglob("*.pdf")`, `rglob("*.py")`, `glob("**/*")`, `Path.walk()` — + the PDF walks were already outside the detector; +- anything outside `surveyors/` and `ingestion/` — `cli/wizard.py` walked + a user-supplied docs directory with no rule; +- an allowlist entry whose file was renamed died silently. + +The rewrite is per **function** and **AST**-based over the whole package: +every function containing a tree walk (`.rglob(...)`, `os.walk(...)`, +`.walk(...)` on a path-like receiver, `.glob("**...")`) must reach +`is_vendored` / `is_vendored_abs` / `VENDORED_DIRS` through its own +call graph, resolved transitively within the module. The allowlist is +keyed by (module, function) and every entry must still name a function +that contains a walk. + +## Made to fail, on purpose + +A green guard proves only that it did not fire. Three deliberate failures, +each reverted after capture: + +**1. A bare walk appended to `pipeline.py`** — the file the first guard +could not see into: + +``` +E AssertionError: functions that walk a tree and never reach the vendored rule (module, function, line of first walk): ingestion/pipeline.py:_sixth_walk@902 +E assert [('ingestion/...h_walk', 902)] == [] +E +E Left contains one more item: ('ingestion/pipeline.py', '_sixth_walk', 902) +E Use -v to get more diff +1 failed, 9 deselected, 2 warnings in 3.30s +``` + +**2. An allowlist entry removed** — the exempt walk becomes an offender: + +``` +E AssertionError: functions that walk a tree and never reach the vendored rule (module, function, line of first walk): ingestion/line_census.py:census_tree@256 +E assert [('ingestion/...s_tree', 256)] == [] +E +E Left contains one more item: ('ingestion/line_census.py', 'census_tree', 256) +E Use -v to get more diff +1 failed, 9 deselected, 2 warnings in 3.72s +``` + +**3. An allowlist entry naming a function that does not exist** — the +rename case, which used to pass silently: + +``` +E AssertionError: allowlist entries that no longer name a walk (renamed or removed?): [('ingestion/pipeline.py', '_walk_that_was_renamed')] +E assert [('ingestion/...was_renamed')] == [] +E +E Left contains one more item: ('ingestion/pipeline.py', '_walk_that_was_renamed') +E Use -v to get more diff +1 failed, 9 deselected, 2 warnings in 3.27s +``` + +## What the allowlist holds, and why + +| module : function | why it may skip the rule | +|---|---| +| `ingestion/line_census.py : census_tree` | reads `VENDORED_DIRS` by its old name `_EXCLUDED_DIRS`; pinned by test | +| `ingestion/dependency_parser.py : parse` | manifest walk; excludes vendor/node_modules ad hoc per manifest kind | +| `ingestion/pipeline.py : _store_file_inventory` | records **every** file on purpose — the inventory carries provenance rather than dropping files; `registry.upsert_file_inventory` stamps the flag | +| `surveyors/arch_recovery/* ` (four functions) | arch recovery has its own exclusion module | +| `github/source_cache.py : _entries` | sums on-disk size of cache entries; not a measurement of repository content | + +Two things the rewrite found on its first run, both fixed in the same PR: +`_local_files_for_paths` in `pipeline.py` referenced `local_root`, which +is not bound there — a `NameError` on any `extra_docs_paths` directory, +introduced by #36 with no test on that branch — and the extra-path PDF walk +checked vendored-ness against `local_root` when the extra directory lives +outside it, so `is_vendored_abs` failed open and never excluded anything. +Both now test against the extra directory itself. diff --git a/packages/resource-explorer/resource_explorer/cli/wizard.py b/packages/resource-explorer/resource_explorer/cli/wizard.py index 747a77d47..10949a9b3 100644 --- a/packages/resource-explorer/resource_explorer/cli/wizard.py +++ b/packages/resource-explorer/resource_explorer/cli/wizard.py @@ -9,6 +9,7 @@ from rich.table import Table from resource_explorer.github.analyzer import RepoAnalyzer +from resource_explorer.ingestion.vendored import is_vendored_abs from resource_explorer.registry import Project, ProjectRegistry @@ -245,7 +246,7 @@ def _augment_plan_for_extra_paths(self, plan, extra_docs_paths: list[str], local found_exts.add(p.suffix.lower()) elif p.is_dir(): for f in p.rglob("*"): - if f.is_file(): + if f.is_file() and not is_vendored_abs(f, p): found_exts.add(f.suffix.lower()) for name in candidates: if name not in proposed_names: diff --git a/packages/resource-explorer/resource_explorer/ingestion/pipeline.py b/packages/resource-explorer/resource_explorer/ingestion/pipeline.py index d33197ab6..44c183f22 100644 --- a/packages/resource-explorer/resource_explorer/ingestion/pipeline.py +++ b/packages/resource-explorer/resource_explorer/ingestion/pipeline.py @@ -625,7 +625,10 @@ def _local_files_for_paths( pass elif abs_path.is_dir(): for f in abs_path.rglob("*"): - if not f.is_file() or f.suffix.lower() not in extensions or is_vendored_abs(f, local_root): + # An extra path lives outside local_root; vendored-ness is + # relative to the extra directory itself. (Was `local_root`, + # unbound here -- a NameError on any extra docs directory.) + if not f.is_file() or f.suffix.lower() not in extensions or is_vendored_abs(f, abs_path): continue try: content = f.read_text(encoding="utf-8", errors="ignore") @@ -852,7 +855,7 @@ def _handle(display: str, abs_str: str) -> None: _handle(f"{display}/{abs_path.name}", str(abs_path)) elif abs_path.is_dir(): for pdf in abs_path.rglob("*.pdf"): - if is_vendored_abs(pdf, local_root): + if is_vendored_abs(pdf, abs_path): # relative to the extra dir, not local_root continue _handle(f"{display}/{pdf.relative_to(abs_path)}", str(pdf)) diff --git a/packages/resource-explorer/resource_explorer/web/static/next/app.js b/packages/resource-explorer/resource_explorer/web/static/next/app.js index 215412078..aa19b85ef 100644 --- a/packages/resource-explorer/resource_explorer/web/static/next/app.js +++ b/packages/resource-explorer/resource_explorer/web/static/next/app.js @@ -2496,8 +2496,13 @@ function resourceHeaderHtml(slug) { } /** The dated verdict trail for one repo. */ -async function renderDispositionHistory(githubUrl) { - const el = $('disposition-history'); +// `target` names the element to fill. Two places show the trail -- the +// header popover and the Disposition pane -- and they carried the same id +// until 2026-09-12; getElementById found the popover's (earlier in the +// DOM) once it had been opened, so the pane's trail never refreshed again +// and the no-URL message landed in the popover instead of the pane. +async function renderDispositionHistory(githubUrl, target = 'disposition-history') { + const el = $(target); if (!el) return; let rows; try { @@ -2554,13 +2559,13 @@ function bindResourceHeader() { : 'border border-rule-strong text-ink hover:border-accent'}" >${esc(d)}`).join('')} -
    Loading history…
    `; +
    Loading history…
    `; // The HISTORY, alongside the picker. It exists in the current UI and // nowhere in /next, and it is the only place the SEQUENCE of verdicts is // visible — which is the rationale trail, not decoration. A single // current value cannot say that something was abandoned and then picked // back up. - renderDispositionHistory(p.github_url); + renderDispositionHistory(p.github_url, 'disposition-history-popover'); slot.querySelectorAll('[data-disp]').forEach((b) => b.addEventListener('click', async () => { const value = b.dataset.disp; note('Saving…'); diff --git a/packages/resource-explorer/tests/test_vendored.py b/packages/resource-explorer/tests/test_vendored.py index fd8406d88..97f4b060b 100644 --- a/packages/resource-explorer/tests/test_vendored.py +++ b/packages/resource-explorer/tests/test_vendored.py @@ -93,31 +93,154 @@ class TestEveryWalkReadsTheRule: """PR #36 fixed the pipeline's walks and missed the survey step's duplicate of one of them, which put 20,654 vendored symbols back one survey later. A walk that measures the repository's content and does - not consult the rule is the shape of that regression, so this lists - every whole-tree walk and requires each file to read `is_vendored`, - or to be on the short allowlist of walks that have their own rule. + not consult the rule is the shape of that regression. + + The first version of this guard (#39) was two regex literals and a + file-level substring test: a file with any walk had to mention + `is_vendored` somewhere. Reviewed 2026-09-12, it missed exactly the + shape it was for -- a sixth bare walk appended to pipeline.py, which + already mentions the rule five times, was invisible -- and it never saw + `rglob("*.pdf")`, `glob("**/*")`, `Path.walk()`, or anything outside + surveyors/ and ingestion/ (cli/wizard.py walked a docs directory with + no rule). This version walks the AST of every module in the package: + each FUNCTION that contains a tree walk must reach the rule through its + own call graph, resolved transitively within the module. The allowlist + is per function and every entry must still name a walk that exists, so + a rename cannot retire an exemption silently. The recorded failures + that proved the detector fires are in docs/vendored-guard.md. """ + #: (module, function) -> why this walk may skip the rule. ALLOWED_WITHOUT = { - "ingestion/line_census.py", # reads VENDORED_DIRS by its old name - "ingestion/dependency_parser.py", # excludes vendor/node_modules ad hoc per manifest - "surveyors/arch_recovery/spring_app.py", # arch recovery has its own exclusion module - "surveyors/arch_recovery/exclusion.py", + ("ingestion/line_census.py", "census_tree"): "reads VENDORED_DIRS by its old name _EXCLUDED_DIRS (pinned by test_line_census_reads_the_same_rule)", + ("ingestion/dependency_parser.py", "parse"): "manifest walk; excludes vendor/node_modules ad hoc per manifest kind", + ("ingestion/pipeline.py", "_store_file_inventory"): "records EVERY file on purpose -- provenance, not exclusion; registry.upsert_file_inventory stamps the flag", + ("surveyors/arch_recovery/exclusion.py", "_walk"): "IS the arch-recovery exclusion rule", + ("surveyors/arch_recovery/spring_app.py", "_spring_entry_points"): "arch recovery has its own exclusion module", + ("surveyors/arch_recovery/spring_app.py", "_properties_files"): "arch recovery has its own exclusion module", + ("surveyors/arch_recovery/spring_app.py", "_style_references"): "arch recovery has its own exclusion module", + ("github/source_cache.py", "_entries"): "sums the on-disk size of cache entries; not a measurement of repository content", } + RULE_NAMES = {"is_vendored", "is_vendored_abs", "VENDORED_DIRS"} + + @staticmethod + def _is_tree_walk(call: "ast.Call") -> bool: + import ast + f = call.func + if not isinstance(f, ast.Attribute): + return False + if f.attr == "rglob": + return True + if f.attr == "walk": + # os.walk(root) and Path.walk(). tree-sitter's node.walk() / + # tree.walk() is a cursor over a syntax tree, not a filesystem: + # a no-argument .walk() on a receiver not named like a path is + # left alone. (`root.walk()` and `path.walk()` still count.) + if isinstance(f.value, ast.Name) and f.value.id == "os": + return True + if isinstance(f.value, ast.Name) and f.value.id == "ast": + return False # Python's own ast.walk(node): a syntax tree + if call.args: + return True + recv = f.value.id if isinstance(f.value, ast.Name) else getattr(f.value, "attr", "") + return any(k in recv.lower() for k in ("path", "root", "dir")) + if f.attr == "glob": + return any(isinstance(a, ast.Constant) and isinstance(a.value, str) and "**" in a.value + for a in call.args) + return False + + @classmethod + def _functions_with_walks(cls, tree: "ast.Module"): + """Yield (name, walks, callees, reads_rule) per function. Nested + functions are folded into their enclosing function -- a walk inside + a local helper is the enclosing function's walk.""" + import ast + for node in tree.body: + fns = [] + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + fns.append(node) + elif isinstance(node, ast.ClassDef): + fns.extend(n for n in node.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))) + for fn in fns: + walks, callees, reads_rule = [], set(), False + for sub in ast.walk(fn): + if isinstance(sub, ast.Call): + if cls._is_tree_walk(sub): + walks.append(sub.lineno) + f = sub.func + if isinstance(f, ast.Name): + callees.add(f.id) + elif isinstance(f, ast.Attribute): + callees.add(f.attr) # self._helper(...) -> _helper + if isinstance(sub, ast.Name) and sub.id in cls.RULE_NAMES: + reads_rule = True + if isinstance(sub, ast.Attribute) and sub.attr in cls.RULE_NAMES: + reads_rule = True + yield fn.name, walks, callees, reads_rule + + @classmethod + def _scan(cls, root): + """Return (offenders, seen): offenders are (module, function, + first_walk_line) that contain a walk and never reach the rule; seen + is every (module, function) that contains a walk.""" + import ast + offenders, seen = [], set() + for f in sorted(root.rglob("*.py")): + rel = str(f.relative_to(root)) + tree = ast.parse(f.read_text(encoding="utf-8")) + fns = {name: (walks, callees, reads) for name, walks, callees, reads in cls._functions_with_walks(tree)} + + def reaches(name, stack=()): + # transitive closure within the module: a function reaches + # the rule if it reads it or calls a same-module function that does + walks, callees, reads = fns[name] + if reads: + return True + return any(c in fns and c not in stack and reaches(c, stack + (name,)) for c in callees) - def test_whole_tree_walks_consult_the_rule(self): - import re + for name, (walks, callees, reads) in fns.items(): + if not walks: + continue + seen.add((rel, name)) + if (rel, name) in cls.ALLOWED_WITHOUT: + continue + if not reaches(name): + offenders.append((rel, name, walks[0])) + return offenders, seen + + def test_every_walk_reaches_the_rule(self): from pathlib import Path root = Path(__file__).resolve().parents[1] / "resource_explorer" - offenders = [] - for f in list((root / "surveyors").rglob("*.py")) + list((root / "ingestion").rglob("*.py")): - rel = str(f.relative_to(root)) - src = f.read_text(encoding="utf-8") - walks = re.findall(r'\.rglob\("\*"\)|\.rglob\(\'\*\'\)|os\.walk\(', src) - if not walks or rel in self.ALLOWED_WITHOUT: - continue - if "is_vendored" not in src and "VENDORED_DIRS" not in src: - offenders.append(rel) - assert offenders == [], f"whole-tree walks that never consult the vendored rule: {offenders}" + offenders, seen = self._scan(root) + assert offenders == [], ( + "functions that walk a tree and never reach the vendored rule " + "(module, function, line of first walk): " + ", ".join(f"{m}:{fn}@{ln}" for m, fn, ln in offenders)) + stale = sorted(k for k in self.ALLOWED_WITHOUT if k not in seen) + assert stale == [], f"allowlist entries that no longer name a walk (renamed or removed?): {stale}" + + def test_the_detector_fires_on_a_bare_walk_in_a_file_that_already_reads_the_rule(self, tmp_path): + """The shape the first guard missed. One module, one function that + reads the rule, one that walks without it.""" + (tmp_path / "m.py").write_text( + "from resource_explorer.ingestion.vendored import is_vendored_abs\n" + "def good(root):\n" + " return [p for p in root.rglob('*') if not is_vendored_abs(p, root)]\n" + "def helper(root):\n" + " return [p for p in root.rglob('*.pdf') if not is_vendored_abs(p, root)]\n" + "def via_helper(root):\n" + " return helper(root)\n" + "class C:\n" + " def bad(self, root):\n" + " return list(root.rglob('*.py'))\n" + "def bad_glob(root):\n" + " return list(root.glob('**/*.md'))\n" + "def fine_glob(root):\n" + " return list(root.glob('*.md'))\n" + ) + offenders, seen = self._scan(tmp_path) + assert [(m, fn) for m, fn, _ in offenders] == [("m.py", "bad"), ("m.py", "bad_glob")] + # `seen` is "contains a walk": via_helper reaches the rule but has no + # walk of its own, and a single-level glob is not a tree walk + assert seen == {("m.py", "good"), ("m.py", "helper"), ("m.py", "bad"), ("m.py", "bad_glob")} def test_the_survey_symbol_walk_skips_vendored(self, tmp_path): from resource_explorer.surveyors.sub_surveyors.symbol_extraction import _local_files @@ -125,3 +248,30 @@ def test_the_survey_symbol_walk_skips_vendored(self, tmp_path): (tmp_path / "src" / "own.py").write_text("x = 1\n") (tmp_path / "node_modules" / "t" / "theirs.py").write_text("y = 2\n") assert [p for p, _ in _local_files(tmp_path, [".py"])] == ["src/own.py"] + + +class TestExtraDocsPaths: + """`_local_files_for_paths` is the reader for extra_docs_paths. Its + directory branch referenced `local_root`, unbound in that method -- a + NameError on any extra docs DIRECTORY, shipped by #36 with no test on + the branch (found in the designer's 2026-09-12 review). An extra path + lives outside the clone, so vendored-ness is relative to the extra + directory itself, not to local_root; against local_root the check + failed open and excluded nothing.""" + + def test_a_directory_entry_is_read_and_its_vendored_files_skipped(self, tmp_path): + from resource_explorer.ingestion.pipeline import IngestionPipeline + docs = tmp_path / "elsewhere" / "docs" + (docs / "guide").mkdir(parents=True) + (docs / "node_modules" / "x").mkdir(parents=True) + (docs / "guide" / "a.md").write_text("# own\n") + (docs / "node_modules" / "x" / "b.md").write_text("# theirs\n") + (docs / "skip.txt").write_text("not a doc\n") + got = IngestionPipeline._local_files_for_paths( + object(), [("docs", docs)], [".md"]) + assert got == [("docs/guide/a.md", "# own\n")] + + def test_a_file_entry_still_works(self, tmp_path): + from resource_explorer.ingestion.pipeline import IngestionPipeline + f = tmp_path / "README.md"; f.write_text("hi\n") + assert IngestionPipeline._local_files_for_paths(object(), [("readme", f)], [".md"]) == [("readme", "hi\n")] From 4c21b4a83d4fdb53b44290fad8f86d3d546c9e34 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sat, 12 Sep 2026 07:38:39 -0500 Subject: [PATCH 20/37] =?UTF-8?q?RE=20experiment:=20within-run=20A/B=20?= =?UTF-8?q?=E2=80=94=20instruction=20variants,=20back-to-back=20conditions?= =?UTF-8?q?,=20schedule=20pause,=20rubric=20v4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 8 could not attribute its one sentence: between runs the Automate scheduler re-surveyed two of three repos and the catalog moved, so a run-over-run comparison carries three variables. This makes the comparison happen inside one run instead. - context_compile: INSTRUCTION_VARIANTS {"default": production (carries the yes/no sentence since PR #38), "no_yesno_line": the same without it}; compile_context(instructions_variant=...) validates the name and records it in the manifest; the variant is in the compile id because it is in the instructions candidate's text. - ConversationAgent(instructions_variant=...) passes it through. - registry: pause_schedules / resume_schedules - disable a resource's enabled schedules and restore them with their ORIGINAL next_run (save_schedule would recompute from now and shift the cadence). - harness: CONDITION_SPECS (compiled, compiled-plain, rag) chosen with --conditions; every question answered under all conditions back-to-back in one process; one reference compile per variant; the run pauses the repos' schedules for its window and restores them in finally (--no-pause-schedules opts out); rows carry instructions_variant. - rubric v4: the judge is told which analyses the catalog maps to the question; a decline scores 0 only when a MAPPED analysis is in the evidence and answers it; an absence asserted over "0 checked / N unqueryable" evidence is a misread even if "none" appears in the text. Smoke (docling, 1 question, 3 conditions): replay 2/2, v4 on every row, docling's two schedules restored with their original next_run. 171 tests. Run 9 (468 rows) is measuring. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Dan Wolfson --- .../docs/experiments/compiled-vs-rag.md | 7 +- .../agents/conversation_agent.py | 7 ++ .../resource_explorer/context_compile.py | 37 +++++++- .../resource_explorer/registry.py | 38 ++++++++ .../scripts/experiment_compiled_vs_rag.py | 94 +++++++++++++++---- .../tests/test_compile_persistence.py | 26 +++++ .../tests/test_context_compile.py | 33 +++++++ ...st_conversation_agent_compiled_evidence.py | 14 +++ 8 files changed, 231 insertions(+), 25 deletions(-) diff --git a/packages/resource-explorer/docs/experiments/compiled-vs-rag.md b/packages/resource-explorer/docs/experiments/compiled-vs-rag.md index 577b5e6b5..8694a323d 100644 --- a/packages/resource-explorer/docs/experiments/compiled-vs-rag.md +++ b/packages/resource-explorer/docs/experiments/compiled-vs-rag.md @@ -14,11 +14,12 @@ Results are appended to this file's "Runs" section as they land; none yet. | Unit | one (repository, catalog question, condition) | | Questions | the full repo question catalog (52 entries on 2026-09-08), shuffled with a fixed seed so order does not follow catalog position; `--limit N` takes the first N of that order | | Repositories | `egeria_python_git`, `kafka`, `docling` by default: the three with the deepest stored analysis results across different languages and communities | -| Conditions | `compiled`: `ConversationAgent(compiled_evidence=True)`, production behaviour. `rag`: the same agent with `compiled_evidence=False`, so it must search collections itself. **Nothing else differs**: same tools, prompt shape, model, tier, fallback | +| Conditions | Named in `CONDITION_SPECS` as (compiled evidence on/off, instructions variant) and chosen per run with `--conditions`; each question is answered under every condition **back-to-back in one process**. `compiled`: `ConversationAgent(compiled_evidence=True)`, production wording. `compiled-plain`: production minus the yes/no sentence (`instructions_variant="no_yesno_line"`) — the other arm of the within-run A/B. `rag`: the same agent with `compiled_evidence=False`, so it must search collections itself. **Nothing else differs**: same tools, prompt shape, model, tier, fallback | +| Stored state | The run **disables the three repos' Automate schedules for its window and restores them afterwards with their original `next_run`** (`registry.pause_schedules` / `resume_schedules`, `--no-pause-schedules` to opt out). Added after run 8, when the nightly `RepoRefreshSurvey` re-surveyed two repos while the run was answering | | Answering model | whatever `LLM__OLLAMA__MODEL` and `EXPLORER_MODEL_TIER` resolve to; record them with each run (default `llama3.1:8b`, tier `dev`) | | Perspectives | none set, the common case | | Judge | `qwen2.5:32b` at temperature 0 with JSON output, a different and larger model than the answerer. Override with `EXPERIMENT_JUDGE_MODEL` | -| Ground truth for the judge | a **reference compile** for every row, in both conditions: which analyses had usable stored results and which are gaps, with their fact-layer states. The judge never sees the packed text, only the list | +| Ground truth for the judge | a **reference compile** per (question, instructions variant): which analyses had usable stored results and which are gaps, with their fact-layer states; since rubric v3 the packed text itself, stored on the row; since v4 also the analyses the catalog maps to the question | ## Rubric (per row, judged) @@ -74,6 +75,8 @@ MLflow is reachable, experiment `compiled_vs_rag` with one run per condition. | v3-2026-09-09 | the judge receives the packed evidence text; `supported_claims` and `misread_claims` counted separately from `unsupported_claims`, which is now checked claim by claim against the text; a decline scores 0 when a packed section answers the question; `evidence_text_exact` records whether a re-judged row's text was reconstructed with a matching compile id | run-4 audit: 89% of the claims v2 counted as unsupported were in the packed text; v2's flags were uncorrelated with the eleven real defects; run 3 showed a decline that names an analysis scoring 2 even when that analysis was packed | +| v4-2026-09-12 | the judge is told which analyses the catalog maps to the question; a decline scores 0 only when a *mapped* analysis is in the evidence and answers it (an unmapped section that touches the topic no longer makes a refusal wrong); an absence or clean result asserted over evidence saying nothing or only part could be checked ("not checked: 0 of 61", "N could not be queried") is a misread even if "none" appears in the evidence | v3's decline rule over-fired on 7 of 9 flagged declines (catalog-state, cost and organisational-use questions no analysis covers); v3 scored docling's "No, there are no outstanding CVEs" as supported against "none in 0 of 61" (run 5) | + Rows carry `judge.rubric_version`; a re-judge (`--rejudge`) rescores existing answers under the current rubric into `results..jsonl`, keeping the previous verdict as `judge_previous`. Runs judged under different rubrics are never averaged together. diff --git a/packages/resource-explorer/resource_explorer/agents/conversation_agent.py b/packages/resource-explorer/resource_explorer/agents/conversation_agent.py index df8ce4990..8acd948c7 100644 --- a/packages/resource-explorer/resource_explorer/agents/conversation_agent.py +++ b/packages/resource-explorer/resource_explorer/agents/conversation_agent.py @@ -34,10 +34,16 @@ def __init__( resource_slug: str | None = None, rag_system=None, compiled_evidence: bool = True, + instructions_variant: str = "default", **kwargs, ) -> None: super().__init__(**kwargs) self.resource_slug = resource_slug + #: Which wording of the compiler's instructions this agent asks for + #: (context_compile.INSTRUCTION_VARIANTS). Experiments only, like + #: `compiled_evidence`: the within-run A/B answers the same question + #: under two variants back-to-back. Production leaves it "default". + self.instructions_variant = instructions_variant #: False turns _compiled_evidence() off for this instance and nothing #: else — same tools, same prompt shape, same fallback. It exists for #: the compiled-versus-RAG experiment (scripts/experiment_compiled_vs_rag.py), @@ -242,6 +248,7 @@ def _compiled_evidence(self, query: str, slug: str, perspectives) -> list[str]: # tools for anything the compile could not reach. budget=6000, session_id=getattr(self, "session_id", None), + instructions_variant=self.instructions_variant, ) except Exception: logger.debug("compiled evidence unavailable for %s", slug, exc_info=True) diff --git a/packages/resource-explorer/resource_explorer/context_compile.py b/packages/resource-explorer/resource_explorer/context_compile.py index 8c3fa16b9..26cda2b42 100644 --- a/packages/resource-explorer/resource_explorer/context_compile.py +++ b/packages/resource-explorer/resource_explorer/context_compile.py @@ -90,6 +90,29 @@ "Do not infer from absence." ) +#: Named instruction variants, so an experiment can put two instruction +#: wordings in ONE run over the same stored state -- the within-run A/B that +#: run 8 (2026-09-12) showed is the only design that attributes anything +#: here: between two runs the Automate scheduler re-surveys the repos and +#: the catalog moves, so run-over-run comparisons carry three variables. +#: The variant changes the instructions candidate's text, so it is part of +#: the compile id without any further bookkeeping; the manifest names it. +#: "default" is production, which since PR #38 carries run 8's yes/no +#: sentence; "no_yesno_line" is the same wording without it, so the A/B +#: measures that sentence on its own. +_YESNO_SENTENCE = ( + "If the question asks yes or no, give the yes or no and then the evidence " + "line it rests on — the analysis, the value, and any coverage limit or caveat " + "shown beside it; a bare yes or no is not an answer. " +) +_YESNO_SENTENCE_SHORT = "A yes/no answer must state the evidence line and its coverage limit. " +assert _YESNO_SENTENCE in _INSTRUCTIONS and _YESNO_SENTENCE_SHORT in _INSTRUCTIONS_SHORT +INSTRUCTION_VARIANTS: dict[str, tuple[str, str]] = { + "default": (_INSTRUCTIONS, _INSTRUCTIONS_SHORT), + "no_yesno_line": (_INSTRUCTIONS.replace(_YESNO_SENTENCE, ""), + _INSTRUCTIONS_SHORT.replace(_YESNO_SENTENCE_SHORT, "")), +} + #: How many evidence sections a compile packs, counted after ranking. Ranking #: still orders and never excludes at the DERIVATION level — every catalog #: question that reaches an analysis stays in `derivation`, and the sections @@ -768,6 +791,7 @@ def compile_context( target_model: str = "", session_id: str | None = None, max_sections: int | None = None, + instructions_variant: str = "default", ) -> CompiledContext: """Build, resolve and pack a context for `question` about resource `slug`. @@ -777,8 +801,14 @@ def compile_context( compile served, when there is one; it lands on the row, not in the hash. `max_sections` caps how many ranked evidence sections compete for the budget (default MAX_EVIDENCE_SECTIONS; 0 means no cap); the rest are - listed in the manifest as `deferred`. + listed in the manifest as `deferred`. `instructions_variant` selects a + wording from INSTRUCTION_VARIANTS (experiments only; production is + "default") and is named in the manifest. """ + if instructions_variant not in INSTRUCTION_VARIANTS: + raise ValueError(f"unknown instructions_variant {instructions_variant!r}; " + f"expected one of {sorted(INSTRUCTION_VARIANTS)}") + instr_full, instr_short = INSTRUCTION_VARIANTS[instructions_variant] from resource_explorer.surveyors.question_catalog_reader import get_questions entries = get_questions( @@ -989,8 +1019,8 @@ def compile_context( caveat_line = caveat_line_short = "" candidates["instructions"] = Candidate( "instructions", - {Rung.FULL: coverage_line + caveat_line + _INSTRUCTIONS, - Rung.SUMMARY: coverage_line_short + caveat_line_short + _INSTRUCTIONS_SHORT, + {Rung.FULL: coverage_line + caveat_line + instr_full, + Rung.SUMMARY: coverage_line_short + caveat_line_short + instr_short, Rung.IDENTIFIERS: _INSTRUCTIONS_BARE}) spec = ContextSpec( @@ -1019,6 +1049,7 @@ def compile_context( # which offered sections had nothing; this says whether the # question was ever a question stored analyses answer. "coverage": coverage, + "instructions_variant": instructions_variant, # Judged, not merely listed. The packer knows only that a section # had no candidate; the fact layer knows whether that is a zero or # an absence, and they are opposite answers to the same question. diff --git a/packages/resource-explorer/resource_explorer/registry.py b/packages/resource-explorer/resource_explorer/registry.py index 9bac98d72..2fdaba6fe 100644 --- a/packages/resource-explorer/resource_explorer/registry.py +++ b/packages/resource-explorer/resource_explorer/registry.py @@ -3315,6 +3315,44 @@ def delete_schedule(self, entity_type: str, entity_slug: str, analysis_id: str) ) return cur.rowcount > 0 + def pause_schedules(self, entity_type: str, entity_slug: str) -> list[dict]: + """Disable every enabled schedule on one resource and return what was + disabled (analysis_id, next_run) so `resume_schedules` can put it back + exactly. For an experiment window: on 2026-09-11 the nightly + RepoRefreshSurvey re-surveyed two of three experiment repos while a run + was answering, so its rows described two states. Pausing and restoring + keeps the schedule's own cadence — `save_schedule(enabled=True)` would + recompute next_run from now and silently shift it by a period.""" + with self._conn() as conn: + rows = conn.execute( + "SELECT analysis_id, next_run FROM resource_schedules " + "WHERE entity_type=? AND entity_slug=? AND enabled=1", + (entity_type, entity_slug), + ).fetchall() + saved = [dict(r) for r in rows] + for r in saved: + conn.execute( + "UPDATE resource_schedules SET enabled=0 " + "WHERE entity_type=? AND entity_slug=? AND analysis_id=?", + (entity_type, entity_slug, r["analysis_id"]), + ) + return saved + + def resume_schedules(self, entity_type: str, entity_slug: str, saved: list[dict]) -> int: + """Re-enable the schedules `pause_schedules` returned, with their + original next_run. A next_run already in the past simply becomes due, + which is the scheduler's normal case after downtime.""" + n = 0 + with self._conn() as conn: + for r in saved: + cur = conn.execute( + "UPDATE resource_schedules SET enabled=1, next_run=? " + "WHERE entity_type=? AND entity_slug=? AND analysis_id=?", + (r.get("next_run") or "", entity_type, entity_slug, r["analysis_id"]), + ) + n += cur.rowcount + return n + def get_due_schedules(self) -> list[dict]: """Return all enabled schedules that are past their next_run time.""" from datetime import timezone diff --git a/packages/resource-explorer/scripts/experiment_compiled_vs_rag.py b/packages/resource-explorer/scripts/experiment_compiled_vs_rag.py index 87ead8053..68ca0ddf3 100644 --- a/packages/resource-explorer/scripts/experiment_compiled_vs_rag.py +++ b/packages/resource-explorer/scripts/experiment_compiled_vs_rag.py @@ -42,10 +42,21 @@ import requests -CONDITIONS = ("compiled", "rag") +#: Every condition is (compiled_evidence, instructions_variant). A run names +#: the ones it wants with --conditions; each question is answered under all +#: of them back-to-back in one process, so two compiled arms see the same +#: stored state -- the within-run A/B that run 8 showed is the only design +#: that attributes anything here (the scheduler re-surveys repos and the +#: catalog moves between runs). +CONDITION_SPECS = { + "compiled": (True, "default"), # production wording (carries the yes/no sentence since PR #38) + "compiled-plain": (True, "no_yesno_line"), # the same without that sentence: the A/B's other arm + "rag": (False, "default"), +} +CONDITIONS = ("compiled", "rag") # the default pair; --conditions overrides #: Bump when JUDGE_PROMPT changes in a way that alters scores; every judged row #: carries it, so runs judged under different rubrics are never averaged together. -RUBRIC_VERSION = "v3-2026-09-09" +RUBRIC_VERSION = "v4-2026-09-12" DEFAULT_REPOS = "egeria_python_git,kafka,docling" DEFAULT_OUT = Path("data/experiments/compiled_vs_rag") JUDGE_MODEL = os.environ.get("EXPERIMENT_JUDGE_MODEL", "qwen2.5:32b") @@ -59,6 +70,7 @@ - Analyses with stored results that were available as evidence: {packed} - Analyses with NO usable result for this repository (each one is a GAP): {gaps} (states: never_run = never executed; nothing_found = ran and found nothing; not_established = ran but cannot be credited; partial) +- Analyses the question catalog maps to THIS question (the ones that would answer it if they have results): {mapped} THE STORED EVIDENCE ITSELF (the exact text compiled from those analyses; sections are headed "## "; a section marked "structure only" lists part names without values and is NOT a result): \"\"\"{evidence}\"\"\" @@ -68,13 +80,13 @@ Return ONLY a JSON object with these keys: - "declines": true/false — does the answer decline to answer, saying the evidence or stored analyses do not cover the question (in any wording)? -- "answers_question": 0, 1 or 2. If it does NOT decline: 2 = directly and usefully answers what was asked; 1 = partially; 0 = does not address it. If it DOES decline: first check whether a section in THE STORED EVIDENCE above actually answers the question — if one does, the refusal is wrong and scores 0 whatever it names; otherwise 2 = declines AND names which analysis would answer it and/or what would have to run; 1 = declines and grounds the refusal in what is or is not stored; 0 = a bare refusal with no grounding. Never score a refusal higher for sounding thorough, and never score a guess higher for sounding confident. +- "answers_question": 0, 1 or 2. If it does NOT decline: 2 = directly and usefully answers what was asked; 1 = partially; 0 = does not address it. If it DOES decline: first check whether one of the analyses the catalog MAPS to this question is in the evidence above with a section that answers it — if so, the refusal is wrong and scores 0 whatever it names. If no mapped analysis is in the evidence (the list is empty, or its entries are gaps), declining is right: 2 = declines AND names which analysis would answer it, what would have to run, or that the catalog answers this by human input / a direct field / nothing yet; 1 = declines and grounds the refusal in what is or is not stored; 0 = a bare refusal with no grounding. A section from an UNMAPPED analysis that merely touches the topic does not make a refusal wrong. Never score a refusal higher for sounding thorough, and never score a guess higher for sounding confident. - "cites_evidence": true/false — the answer names a specific analysis, finding, file, metric or source FOR A CLAIM IT MAKES. Naming analyses only to say they were not used does not count. - "missing_result_claims": list of gap analysis ids (from the GAP list above, exact ids) for which the answer states a concrete result AS IF MEASURED. This INCLUDES asserting absence or a zero — e.g. "there is no telemetry", "no CVEs were found", "provenance is handled via CLA" — for a gap whose state is never_run or not_established. Check every gap in the list individually. Empty list if none. - "claims_missing_result": true if missing_result_claims is non-empty, else false. - "acknowledges_limits": true/false — the answer says what it could not determine, what has not been checked, what is unknown or not available, or that the evidence does not cover the question. Any such hedge-shaped statement counts, including "no information available" and "not available in the provided data". - "supported_claims": integer — count of specific factual claims (numbers, names, files, statuses, findings, asserted absences) that ARE stated in, or are a faithful restatement or aggregation of, THE STORED EVIDENCE above. -- "misread_claims": integer — count of specific claims that come from the evidence but get it wrong: a wrong value, a value attributed to the wrong analysis, a "structure only" line or a count of parts quoted as a result, a field name read as a finding, or a "0 checked / unqueryable" read as "none found". +- "misread_claims": integer — count of specific claims that come from the evidence but get it wrong: a wrong value, a value attributed to the wrong analysis, an "abridged" or "structure only" line or a count of parts quoted as a complete result, a field name read as a finding, or — check this one deliberately — an absence or a clean result asserted ("no CVEs", "no vulnerabilities", "no secrets", "none found") where the evidence's headline, caveat or fields say that nothing or only part could be checked (e.g. "not checked: 0 of 61", "checked: 0", "unqueryable: 61", "N could not be queried"). That last case counts as a misread even if the words "none" or "no advisories" appear in the evidence, because the evidence qualifies them and the answer drops the qualification. - "unsupported_claims": integer — count of specific factual claims found NOWHERE in the stored evidence and not clearly labelled as general knowledge. Check each claim against the evidence text before counting it; a claim that appears in the evidence is supported, not unsupported, however specific it is. A statement that evidence is missing or does not cover the question is NOT a claim (count 0 for it). Every entry in missing_result_claims is also an unsupported claim; keep the two consistent. Do not count misread_claims here. - "rationale": one sentence naming the worst misread or unsupported claim if there is one. """ @@ -100,13 +112,20 @@ def load_questions(limit: int | None, seed: int) -> list[dict]: return rows[:limit] if limit else rows -def reference_compile(registry, slug: str, question: str) -> dict: +def reference_compile(registry, slug: str, question: str, variant: str = "default") -> dict: """The manifest a compile produces for this question — ground truth for - the judge in BOTH conditions. Persisted like any compile (same id as the - agent's own compile in the compiled condition, so `hits` goes up).""" + the judge in every condition. Persisted like any compile (same id as the + agent's own compile in a compiled condition, so `hits` goes up). One per + instructions variant, because the variant is in the compile id.""" from resource_explorer.context_compile import compile_context c = compile_context(registry, slug, question, perspectives=[], budget=6000, - session_id="experiment:compiled_vs_rag") + session_id="experiment:compiled_vs_rag", + instructions_variant=variant) + # The analyses the catalog maps to THIS question, for rubric v4's decline + # rule: the top derivation entry when it is the verbatim catalog question + # (relevance 1.0), else nothing -- a paraphrase's nearest entry is a guess. + top = c.derivation[0] if c.derivation else {} + mapped = list(top.get("analysis_ids") or []) if float(top.get("relevance") or 0) >= 1.0 else [] packed = [p["key"] for p in c.manifest.get("packed", []) if p.get("role") == "evidence"] # The rung each section was packed at. Without it, "the rung was too # coarse to answer" cannot be told apart from "the model ignored it" @@ -114,6 +133,8 @@ def reference_compile(registry, slug: str, question: str) -> dict: rungs = {p["key"]: str(p.get("rung")) for p in c.manifest.get("packed", []) if p.get("role") == "evidence"} gaps = [{"key": g["key"], "state": g.get("state"), "reason": g.get("reason")} for g in c.manifest.get("gaps", [])] return {"compile_id": c.compile_id, "packed": packed, "rungs": rungs, "gaps": gaps, + "mapped": mapped, "instructions_variant": variant, + "coverage_kind": (c.manifest.get("coverage") or {}).get("kind"), "used": c.manifest.get("used"), "budget": c.manifest.get("budget"), # Kept so the judge grades against the text the model saw # (rubric v3) without recompiling, and so a later re-judge does @@ -123,7 +144,9 @@ def reference_compile(registry, slug: str, question: str) -> dict: def answer(slug: str, question: str, condition: str) -> tuple[str, float, str | None]: from resource_explorer.agents.conversation_agent import ConversationAgent - agent = ConversationAgent(resource_slug=slug, compiled_evidence=(condition == "compiled")) + compiled_on, variant = CONDITION_SPECS[condition] + agent = ConversationAgent(resource_slug=slug, compiled_evidence=compiled_on, + instructions_variant=variant) agent.session_id = f"experiment:compiled_vs_rag:{condition}" t0 = time.perf_counter() text = agent.handle(question, resource_slug=slug, perspectives=[]) @@ -155,6 +178,7 @@ def judge(question: str, answer_text: str, ref: dict, evidence: str = "") -> dic question=question, packed=", ".join(ref["packed"]) or "(none)", gaps=", ".join(f'{g["key"]} ({g["state"]})' for g in ref["gaps"]) or "(none)", + mapped=", ".join(ref.get("mapped") or []) or "(none — the catalog maps no stored analysis to this question)", evidence=(evidence or "(none)")[:8000], answer=answer_text[:6000], ) @@ -226,22 +250,47 @@ def run(args) -> None: if registry.get(slug) is None: sys.exit(f"unknown resource slug: {slug}") questions = load_questions(args.limit, args.seed) - total = len(repos) * len(questions) * len(CONDITIONS) - print(f"{len(repos)} repos x {len(questions)} questions x {len(CONDITIONS)} conditions = {total} rows; " - f"{len(done)} already done; judge={JUDGE_MODEL}", flush=True) + conditions = [c.strip() for c in args.conditions.split(",") if c.strip()] + unknown = [c for c in conditions if c not in CONDITION_SPECS] + if unknown: + sys.exit(f"unknown condition(s) {unknown}; known: {sorted(CONDITION_SPECS)}") + total = len(repos) * len(questions) * len(conditions) + print(f"{len(repos)} repos x {len(questions)} questions x {len(conditions)} conditions = {total} rows; " + f"{len(done)} already done; judge={JUDGE_MODEL}; rubric={RUBRIC_VERSION}", flush=True) if args.dry_run: return + # Hold the stored state still for the window: the Automate scheduler's + # nightly re-surveys moved two of three repos under run 8. Restored in + # `finally`, with their original next_run, whatever happens below. + paused: dict[str, list[dict]] = {} + if args.pause_schedules: + for slug in repos: + paused[slug] = registry.pause_schedules("repo", slug) + print("paused schedules: " + "; ".join( + f"{slug}: {[r['analysis_id'] for r in rows] or 'none'}" for slug, rows in paused.items()), flush=True) + try: + _run_rows(args, registry, repos, questions, conditions, done, out, total) + finally: + for slug, rows in paused.items(): + n = registry.resume_schedules("repo", slug, rows) + print(f"resumed {n} schedule(s) on {slug}", flush=True) + summarise(out, args) + + +def _run_rows(args, registry, repos, questions, conditions, done, out, total) -> None: i = 0 for slug in repos: for q in questions: - ref = None - for condition in CONDITIONS: + refs: dict[str, dict] = {} + for condition in conditions: i += 1 key = (slug, q["question"], condition) if key in done: continue - if ref is None: - ref = reference_compile(registry, slug, q["question"]) + variant = CONDITION_SPECS[condition][1] + if variant not in refs: + refs[variant] = reference_compile(registry, slug, q["question"], variant) + ref = refs[variant] try: text, latency, cid = answer(slug, q["question"], condition) except Exception as exc: # record the failure as a row; do not stop the run @@ -251,6 +300,7 @@ def run(args) -> None: "run_id": args.run_id, "ts": datetime.now(timezone.utc).isoformat(), "repo": slug, "question": q["question"], "stage": q["stage"], "answering_kind": q["answering_kind"], "condition": condition, + "instructions_variant": variant, "answer": text, "latency_s": round(latency, 2), "agent_compile_id": cid, "reference": ref, "compile_id_matches_reference": (cid == ref["compile_id"]) if cid else None, @@ -263,7 +313,6 @@ def run(args) -> None: print(f"[{i}/{total}] {slug} | {condition:8s} | {latency:5.1f}s | aq={v.get('answers_question')} " f"cites={v.get('cites_evidence')} missing={v.get('claims_missing_result')} " f"unsupported={v.get('unsupported_claims')} | {q['question'][:60]}", flush=True) - summarise(out, args) def summarise(out: Path, args=None) -> None: @@ -289,7 +338,8 @@ def agg(sub): } print("\n=== By condition ===") - table = {c: agg([r for r in rows if r["condition"] == c]) for c in CONDITIONS if any(r["condition"] == c for r in rows)} + present = [c for c in CONDITION_SPECS if any(r["condition"] == c for r in rows)] + table = {c: agg([r for r in rows if r["condition"] == c]) for c in present} keys = list(next(iter(table.values())).keys()) print(f"{'metric':28s} " + " ".join(f"{c:>10s}" for c in table)) for k in keys: @@ -298,13 +348,13 @@ def agg(sub): kinds = sorted({r["answering_kind"] for r in rows}) for kind in kinds: parts = [] - for c in CONDITIONS: + for c in present: sub = [r for r in rows if r["condition"] == c and r["answering_kind"] == kind] if sub: a = agg(sub) parts.append(f"{c}: n={a['n']} aq={a['answers_question(0-2)']} cites={a['cites_evidence%']}% missing={a['claims_missing_result%']}% unsup={a['unsupported_claims(mean)']}") print(f"{kind:10s} | " + " || ".join(parts)) - matches = [r for r in rows if r["condition"] == "compiled" and r.get("compile_id_matches_reference") is not None] + matches = [r for r in rows if CONDITION_SPECS.get(r["condition"], (False,))[0] and r.get("compile_id_matches_reference") is not None] if matches: ok = sum(1 for r in matches if r["compile_id_matches_reference"]) print(f"\nreplayability: agent compile id == reference compile id in {ok}/{len(matches)} compiled rows") @@ -342,6 +392,10 @@ def main() -> None: ap.add_argument("--summarise", action="store_true", help="only re-read results and print the tables") ap.add_argument("--summarise-file", default=None, help="results file to summarise (default results.jsonl)") ap.add_argument("--rejudge", action="store_true", help="re-score existing answers under the current rubric") + ap.add_argument("--conditions", default=",".join(CONDITIONS), + help=f"comma-separated, answered back-to-back per question; known: {sorted(CONDITION_SPECS)}") + ap.add_argument("--no-pause-schedules", dest="pause_schedules", action="store_false", + help="do not disable the repos' Automate schedules for the run window (default: pause and restore)") args = ap.parse_args() if args.summarise: summarise(Path(args.summarise_file) if args.summarise_file else Path(args.out) / "results.jsonl", args); return diff --git a/packages/resource-explorer/tests/test_compile_persistence.py b/packages/resource-explorer/tests/test_compile_persistence.py index a2b02711f..054921e74 100644 --- a/packages/resource-explorer/tests/test_compile_persistence.py +++ b/packages/resource-explorer/tests/test_compile_persistence.py @@ -157,3 +157,29 @@ def test_feedback_records_compile_id_on_the_latest_query_row(self, tmp_path, mon with mc._conn() as conn: row = conn.execute("SELECT feedback, compile_id FROM query_log WHERE query_hash = 'h1'").fetchone() assert (row[0], row[1]) == (1, "abc") + + +class TestPauseAndResumeSchedules: + """For an experiment window. On 2026-09-11 the nightly RepoRefreshSurvey + re-surveyed two of three experiment repos while run 8 was answering.""" + + def test_pause_disables_only_enabled_and_resume_restores_next_run(self, tmp_path): + reg = _sqlite_registry(tmp_path) + reg.save_schedule("repo", "r1", "a_daily", "daily", enabled=True) + reg.save_schedule("repo", "r1", "b_manual", "manual", enabled=False) + reg.save_schedule("repo", "r2", "c_daily", "daily", enabled=True) + before = {s["analysis_id"]: s for s in reg.get_schedules("repo", "r1")} + saved = reg.pause_schedules("repo", "r1") + assert [s["analysis_id"] for s in saved] == ["a_daily"] + during = {s["analysis_id"]: s for s in reg.get_schedules("repo", "r1")} + assert during["a_daily"]["enabled"] == 0 and during["b_manual"]["enabled"] == 0 + assert reg.get_schedules("repo", "r2")[0]["enabled"] == 1 # other resources untouched + assert reg.resume_schedules("repo", "r1", saved) == 1 + after = {s["analysis_id"]: s for s in reg.get_schedules("repo", "r1")} + assert after["a_daily"]["enabled"] == 1 + assert after["a_daily"]["next_run"] == before["a_daily"]["next_run"] # cadence kept, not shifted + assert after["b_manual"]["enabled"] == 0 # was disabled; stays so + + def test_resume_with_nothing_saved_is_a_no_op(self, tmp_path): + reg = _sqlite_registry(tmp_path) + assert reg.resume_schedules("repo", "r1", []) == 0 diff --git a/packages/resource-explorer/tests/test_context_compile.py b/packages/resource-explorer/tests/test_context_compile.py index bedd117c7..7f4c23f77 100644 --- a/packages/resource-explorer/tests/test_context_compile.py +++ b/packages/resource-explorer/tests/test_context_compile.py @@ -1180,3 +1180,36 @@ def test_full_and_short_rungs_ask_for_the_line(self): def test_it_reaches_the_packed_text(self): c = compile_context(_registry({"repo_conventions": [_finding("a")]}), "x", "q", budget=6000) assert "a bare yes or no is not an answer" in c.text + + +class TestInstructionVariants: + """Run 8 (2026-09-12) could not attribute its sentence because the stored + state and the catalog moved between runs. Variants let one run carry two + wordings over the same state; the variant is in the compile id because it + is in the instructions candidate's text. Production ("default") carries + the yes/no sentence since PR #38; "no_yesno_line" removes only it.""" + + def test_default_is_production_wording_with_the_sentence(self): + from resource_explorer.context_compile import INSTRUCTION_VARIANTS, _INSTRUCTIONS + assert INSTRUCTION_VARIANTS["default"][0] == _INSTRUCTIONS + c = compile_context(_registry({"repo_conventions": [_finding("a")]}), "x", "q", budget=6000) + assert "a bare yes or no is not an answer" in c.text + assert c.manifest["instructions_variant"] == "default" + + def test_the_plain_variant_drops_only_the_sentence_and_changes_the_id(self): + from resource_explorer.context_compile import INSTRUCTION_VARIANTS + full, short = INSTRUCTION_VARIANTS["no_yesno_line"] + assert "yes or no" not in full and "yes/no" not in short + assert "Do not infer from absence" in short and "do not infer from absence" in full + reg = _registry({"repo_conventions": [_finding("a")]}) + a = compile_context(reg, "x", "q", budget=6000) + b = compile_context(reg, "x", "q", budget=6000, instructions_variant="no_yesno_line") + assert "a bare yes or no is not an answer" not in b.text + assert b.manifest["instructions_variant"] == "no_yesno_line" + assert a.compile_id != b.compile_id + strip = lambda c: c.text[c.text.index("## "):] + assert strip(a) == strip(b) # same evidence; only the instructions differ + + def test_an_unknown_variant_is_an_error_not_a_default(self): + with pytest.raises(ValueError): + compile_context(_registry({}), "x", "q", budget=6000, instructions_variant="nope") diff --git a/packages/resource-explorer/tests/test_conversation_agent_compiled_evidence.py b/packages/resource-explorer/tests/test_conversation_agent_compiled_evidence.py index 14ddcc5c2..f9ab59e1e 100644 --- a/packages/resource-explorer/tests/test_conversation_agent_compiled_evidence.py +++ b/packages/resource-explorer/tests/test_conversation_agent_compiled_evidence.py @@ -95,3 +95,17 @@ def test_off_means_no_compile_and_no_last_compiled(self, monkeypatch): def test_default_is_on(self): from resource_explorer.agents.conversation_agent import ConversationAgent assert ConversationAgent(resource_slug="x").compiled_evidence is True + + +class TestInstructionsVariantPassthrough: + def test_the_agent_hands_its_variant_to_the_compiler(self, monkeypatch): + from resource_explorer.agents.conversation_agent import ConversationAgent + from resource_explorer import context_compile as cc + seen = {} + def fake(registry, slug, question, **kw): + seen.update(kw); raise RuntimeError("stop here") + monkeypatch.setattr(cc, "compile_context", fake) + agent = ConversationAgent(resource_slug="x", instructions_variant="no_yesno_line") + assert agent._compiled_evidence("q", "x", []) == [] # fail-soft, as before + assert seen.get("instructions_variant") == "no_yesno_line" + assert ConversationAgent(resource_slug="x").instructions_variant == "default" From 5a676d694d98991f66e292de9bb01c485a428932 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sat, 12 Sep 2026 14:35:17 -0500 Subject: [PATCH 21/37] RE tests: the timestamp-gate test asserts over the live corpus, so mark it corpus With the MLflow timeout gone the suite ran to the end on CI for the first time since 2026-09-11: 4,354 passed, 1 failed. The one is test_the_gate_keys_on_the_timestamp_not_on_having_components, which reads the live registry and failed on its own vacuity guard against CI's empty Postgres. Every other live-registry test carries the corpus marker and skips by default; this one did not. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- .../resource-explorer/tests/test_reader_status_wiring.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/resource-explorer/tests/test_reader_status_wiring.py b/packages/resource-explorer/tests/test_reader_status_wiring.py index f6f4fefef..47a267007 100644 --- a/packages/resource-explorer/tests/test_reader_status_wiring.py +++ b/packages/resource-explorer/tests/test_reader_status_wiring.py @@ -162,9 +162,14 @@ def test_every_live_read_reader_reports_absence_as_absence(self): f"genuinely nothing, the way _architecture_diagram_results does." ) + @pytest.mark.corpus def test_the_gate_keys_on_the_timestamp_not_on_having_components(self): """The gate must not swallow the real answers it sits in front of. + Marked `corpus`: it asserts over whatever the live registry holds, + and on CI's empty Postgres it failed on its own vacuity guard -- + the one failure left after #43 let the suite run to the end. + The tempting wrong fix is `if not all_components` — it looks equivalent and is not. Measured 2026-09-09: 8 of 61 repos have ZERO architecture_recovery findings and still carry a surveyed_at, because From 42460e7ebe3ca16daab2328a0bb191c53568bf70 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sat, 12 Sep 2026 14:52:45 -0500 Subject: [PATCH 22/37] RE /next: Enrichment fidelity -- the source renders, owner is marked, all seven questions arrive, the licence confirm is gated Designer review of 2026-09-12, section 3. An observation's source never rendered: the server stamps author on every field, so the source-only branch was dead and a confirmed licence read "alice . 2d ago" with its source stored and invisible. Both halves render now, source first. Owner candidates from contributor data are deferred, and a deferred affordance is marked, never omitted: the row says so with the dashed rule the unbuilt sub-tabs use. "Interim" is a person naming themself -- one click for the signed-in investigator -- not a blank owner saved. Two of the seven human-supplied questions were stage Analysis only, so exact phase matching kept them off the pane whose heading promised the catalog's questions for a person. Widened in the CSV with a catalog-history line; YAML regenerated; a test pins all seven. The licence confirm keyed on the headline, and "No license detected on this repository." has one; it offered that sentence to write into the licence field. Now keyed on the license_risk_tier finding: none offers nothing, a present-but-unclassified licence still offers its name. Also: "what we judge" is visibly the larger set (heading, labels and controls a step up; the recorded set a step down); the evidence rail opens the drawer it writes into and names what it replaced; the offered licence sits in ink, not muted; the four un-tnum'd ages are wrapped; and every timestamp comparison goes through whenMs(), which reads a naive stamp as UTC -- Z, +00:00 and naive all occur, and Date.parse read the naive one as local time. A node-backed test pins the three spellings equal. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- .../docs/dr-egeria/resource_questions.csv | 4 +- .../configdata/question_catalog.yaml | 12 ++- .../resource_explorer/web/static/next/app.js | 100 ++++++++++++++---- .../web/static/next/format.js | 17 ++- .../web/static/next/tailwind-next.css | 2 +- .../tests/test_next_enrichment_fidelity.py | 88 +++++++++++++++ 6 files changed, 193 insertions(+), 30 deletions(-) create mode 100644 packages/resource-explorer/tests/test_next_enrichment_fidelity.py diff --git a/packages/resource-explorer/docs/dr-egeria/resource_questions.csv b/packages/resource-explorer/docs/dr-egeria/resource_questions.csv index af2bb9c7b..4b59bfe87 100644 --- a/packages/resource-explorer/docs/dr-egeria/resource_questions.csv +++ b/packages/resource-explorer/docs/dr-egeria/resource_questions.csv @@ -11,9 +11,9 @@ Does it replace or extend something we already have?,Discovery,Duplication can b What are similar repos/projects? How does this differ?,Analysis,Helping me choose.,"Needs similarity search over content (RAG/embeddings) — real gap, not built.",GAP: similarity search over pgvector embeddings (not built) - Agent over RAG ,Agent-Based Analysis,,,,,,,,X,,X,,X,Select, Are there any restrictions for use?,Analysis,Is this appropriate?,License field is cheaply available from the GitHub API — deeper copyleft-risk categorization is Assessment-tier (see the license-specifics row below).,"N/A — direct field (GitHub license field) - Also Agent over RAG? Direct field (GitHub license field) for a standard license (e.g. Apache 2.0) — the answer for those is itself usually just ""citation/attribution required"". A non-standard license needs the actual license text interpreted, which is where Agent-Based Analysis comes in instead of a direct-field read.",Direct Field (standard license) + Agent-Based Analysis (non-standard license interpretation),,,,,,,,,,X,X,X,Select;Certify, What dependencies does this require?,Analysis,Influences deployment cost and exposes additional transitive risks.,"The declared dependency list, per ecosystem, from the manifests present.",dependency_analysis ,Code Analysis,,,,,X,,,,X,X,,X,Select;Assess;Deploy,Answered by DependencySurveyor (Analysis tier). -Do we already support these dependencies?,Analysis,Reduces risk.,"Needs the org's own tech inventory — human-supplied cross-reference, not resource-derivable.","N/A — human-supplied via Enrichment Context form - may also be a new Agent over RAG content. Cross-reference against Egeria's catalog of already-supported dependencies, validated/completed by human input — likely needs a per-dependency answer, not one blanket answer for the whole question.",Egeria Queries + Human-Supplied,,,,,,,,,,X,X,X,Select;Deploy, +Do we already support these dependencies?,Analysis/Enrichment,Reduces risk.,"Needs the org's own tech inventory — human-supplied cross-reference, not resource-derivable.","N/A — human-supplied via Enrichment Context form - may also be a new Agent over RAG content. Cross-reference against Egeria's catalog of already-supported dependencies, validated/completed by human input — likely needs a per-dependency answer, not one blanket answer for the whole question.",Egeria Queries + Human-Supplied,,,,,,,,,,X,X,X,Select;Deploy,"2026-09-12: stage widened to Analysis/Enrichment — the answer is human-supplied via Enrichment (says so in Answering Analysis), but exact phase matching kept the row off the Enrichment pane, under a heading that promised the catalog's questions for a person." What deployment styles does this support?,Analysis,Need to understand if we can deploy it?,"Covers whether a Dockerfile, compose file or Helm chart is present. What the deployment instructions actually say is not read.",MIXED: repo_conventions (deployment_docker presence signal); GAP: RAG content read for deployment instructions [checks: repo_conventions:deployment_docker],Code Analysis + RAG Queries,,,,,,,,,,X,X,X,Select;Deploy,Presence became a cheap Discovery-tier signal through repo_conventions' deployment_docker check; a content read of Dockerfiles and deployment docs remains a RAG-tier gap. -Do we know what the cost to run it is?,Analysis,Cost is part of the decision process.,Human-supplied/derived — not resource-derivable.,N/A — human-supplied via Enrichment Context form = may also be Agent over RAG content.,Human-Supplied,X,,,,,,,,,,,X,Select;Deploy, +Do we know what the cost to run it is?,Analysis/Enrichment,Cost is part of the decision process.,Human-supplied/derived — not resource-derivable.,N/A — human-supplied via Enrichment Context form = may also be Agent over RAG content.,Human-Supplied,X,,,,,,,,,,,X,Select;Deploy,"2026-09-12: stage widened to Analysis/Enrichment — the answer is human-supplied via Enrichment (says so in Answering Analysis), but exact phase matching kept the row off the Enrichment pane, under a heading that promised the catalog's questions for a person." Is there a validation / deployment test for it?,Analysis,How do we know if it is working properly once deployed?,Covers whether CI runs tests and whether deployment evidence exists; a real deployment validation test is not detected.,"MIXED: ci_quality (CI actually runs tests) + repo_conventions (deployment_docker evidence); GAP: real deployment-validation test - May be RAG queries? ci_quality/repo_conventions give structured signals; an Agent examining the repo's actual file/directory structure (test dirs, validation scripts) can find hints beyond those fixed checks. [checks: ci_quality:ci_runs_tests, repo_conventions:deployment_docker]",Code Analysis + Agent-Based Analysis,,,,,,,,,,X,,X,Assess;Deploy,ci_quality replaced a CI-config-presence check with one that reads whether workflows run tests; repo_conventions' deployment_docker added the deployment-evidence signal. What is the upgrade process?,Analysis,Is it deployable and maintainable? Are upgrades disruptive?,"Needs content read (CHANGELOG, release notes) — RAG-tier, real gap.",GAP: RAG content read (CHANGELOG/release notes) — no structured surveyor,RAG Queries,,,,,X,,,,X,,X,X,Maintain;Deploy, Does it fit into our monitoring infrastructure?,Analysis/Enrichment,"Cost, Skills and manageability.",Org-internal cross-reference — human-supplied.,N/A — human-supplied via Enrichment Context form - We may be able to use targeted Egeria queries first - augmented with enrichment.,Egeria Queries + Human-Supplied,,,,X,X,,,,,,X,X,Deploy;Maintain, diff --git a/packages/resource-explorer/resource_explorer/configdata/question_catalog.yaml b/packages/resource-explorer/resource_explorer/configdata/question_catalog.yaml index b7bb969b7..101577245 100644 --- a/packages/resource-explorer/resource_explorer/configdata/question_catalog.yaml +++ b/packages/resource-explorer/resource_explorer/configdata/question_catalog.yaml @@ -301,7 +301,7 @@ repo_questions: rationale: The declared dependency list, per ecosystem, from the manifests present. catalog_history: Answered by DependencySurveyor (Analysis tier). - question: Do we already support these dependencies? - stage: Analysis + stage: Analysis/Enrichment perspectives: - Security - Architecture @@ -318,7 +318,9 @@ repo_questions: by human input — likely needs a per-dependency answer, not one blanket answer for the whole question. answering_mechanism: Egeria Queries + Human-Supplied rationale: Needs the org's own tech inventory — human-supplied cross-reference, not resource-derivable. - catalog_history: '' + catalog_history: '2026-09-12: stage widened to Analysis/Enrichment — the answer is human-supplied via + Enrichment (says so in Answering Analysis), but exact phase matching kept the row off the Enrichment + pane, under a heading that promised the catalog''s questions for a person.' - question: What deployment styles does this support? stage: Analysis perspectives: @@ -342,7 +344,7 @@ repo_questions: catalog_history: Presence became a cheap Discovery-tier signal through repo_conventions' deployment_docker check; a content read of Dockerfiles and deployment docs remains a RAG-tier gap. - question: Do we know what the cost to run it is? - stage: Analysis + stage: Analysis/Enrichment perspectives: - Financial - Admin @@ -356,7 +358,9 @@ repo_questions: note: N/A — human-supplied via Enrichment Context form = may also be Agent over RAG content. answering_mechanism: Human-Supplied rationale: Human-supplied/derived — not resource-derivable. - catalog_history: '' + catalog_history: '2026-09-12: stage widened to Analysis/Enrichment — the answer is human-supplied via + Enrichment (says so in Answering Analysis), but exact phase matching kept the row off the Enrichment + pane, under a heading that promised the catalog''s questions for a person.' - question: Is there a validation / deployment test for it? stage: Analysis perspectives: diff --git a/packages/resource-explorer/resource_explorer/web/static/next/app.js b/packages/resource-explorer/resource_explorer/web/static/next/app.js index 215412078..6a0580a07 100644 --- a/packages/resource-explorer/resource_explorer/web/static/next/app.js +++ b/packages/resource-explorer/resource_explorer/web/static/next/app.js @@ -25,7 +25,7 @@ // and it goes when the experiment goes. import { listWorkLists, openWorkList, saveAsWorkList, openDialog, closeCellDetail, CELL } from '/static/next/worklist.js'; -import { ago } from '/static/next/format.js'; +import { ago, whenMs } from '/static/next/format.js'; import { ApiError, VALID_DISPOSITIONS, @@ -363,7 +363,7 @@ function readEnvelope(entry, env) { for (const f of facts) { if (f.can_run && f.can_run.length) lines.canRun.push(...f.can_run); if (f.analysis_id) lines.sources.push(f.analysis_id); - if (f.last_run_at && f.last_run_at > lines.lastRun) lines.lastRun = f.last_run_at; + if (f.last_run_at && (!lines.lastRun || whenMs(f.last_run_at) > whenMs(lines.lastRun))) lines.lastRun = f.last_run_at; } lines.sources = [...new Set(lines.sources)]; lines.canRun = [...new Set(lines.canRun)]; @@ -2795,7 +2795,7 @@ async function renderJournalEntries(slug) { data.suggested_to?.length ? ` · suggested to ${esc(data.suggested_to.join(', '))}` : ''} ${entries.map((e) => `

    ${tnum(esc(e.body))}

    -
    ${esc(e.author)} · ${esc(ago(e.written_at))}${ +
    ${esc(e.author)} · ${esc(ago(e.written_at))}${ e.suggested_to?.length ? ` · suggested to ${esc(e.suggested_to.join(', '))}` : ''}
    `).join('')}`; } @@ -4025,7 +4025,7 @@ async function renderDashboardByQuestion(slug, stage, host, live) { const human = q.kind === 'human' ? (state.contextAnswers || {})[questionKey(q.question)] : null; const answerLine = human && human.answer ? `

    ${tnum(esc(human.answer))} - · answered ${esc(ago(human.answered_at))}

    ` + · answered ${esc(ago(human.answered_at))}

    ` : q.kind === 'human' ? `

    needs a person to answer — the analyses below inform it, they do not decide it

    ` : lines && lines.answer @@ -4873,53 +4873,92 @@ function movedSince(field) { const moved = []; for (const [id, at] of Object.entries(field.evidence || {})) { const now = state.enrichmentFacts?.[id]?.last_run_at; - if (now && now > at) moved.push(id); + // Instants, not strings: `Z`, `+00:00` and naive stamps all occur, and + // a string compare between spellings fires or fails on the suffix. + if (now && whenMs(now) > whenMs(at)) moved.push(id); } return moved; } -function fieldControlHtml(def, field) { +function fieldControlHtml(def, field, kind = 'judgement') { const v = field?.value || ''; + // Judgements are the larger set on purpose; the recorded facts sit a + // step down, at provenance size, so the split is visible, not narrated. + const size = kind === 'judgement' ? 'text-answer' : 'text-provenance'; if (def.options) { - return ` ${def.options.map((o) => ``).join('')} `; } return ``; + class="w-full rounded-sm border border-rule-strong bg-transparent px-[6px] py-[2px] ${size} text-ink placeholder:text-ink-muted">`; } function fieldRowHtml(def, kind) { const field = (state.enrichment || {})[def.key]; const moved = field && kind === 'judgement' ? movedSince(field) : []; - const who = field?.author ? `${esc(field.author)}${field.interim ? ' · interim' : ''} · ${esc(ago(field.set_at))}` : ''; + // Judgements carry an author; observations carry a source. The server + // stamps `author` on every field, so a source-only branch was dead code + // and a confirmed licence read as "alice · 2d ago" with its source stored + // and invisible. Both halves render now, in that order. + const when = field?.set_at ? `${esc(ago(field.set_at))}` : ''; + const who = field?.author + ? [kind === 'observation' && field.source ? `from ${esc(field.source)}` : '', + `${esc(field.author)}${field.interim ? ' · interim' : ''}`, when].filter(Boolean).join(' · ') + : ''; const proposed = def.fromAnalysis && !field?.value ? proposedFrom(def.fromAnalysis) : null; + // "What we judge" is the larger of the two sets -- the split's whole + // argument -- so its labels are body size in ink, not caption size muted. + const labelCls = kind === 'judgement' ? 'text-question font-heading text-ink' : 'text-provenance text-ink-muted'; return `
    -
    ${esc(def.label)}
    +
    ${esc(def.label)}
    -
    ${fieldControlHtml(def, field)} +
    ${fieldControlHtml(def, field, kind)}
    ${moved.length ? `⚠ review — evidence moved: ${esc(moved.join(', '))} · ` : ''} - ${who || (field?.source ? `from ${esc(field.source)}` : '')} - ${proposed ? `from survey: ${esc(proposed.value)} · + ${who} + ${proposed ? `from survey: ${esc(proposed.value)} · ` : ''}
    + ${def.key === 'owner' ? ownerNoteHtml(field) : ''}
    `; } -/** A fact a survey already established, offered to confirm — not applied. */ +/** Owner candidates from contributor data are DEFERRED, and a deferred + * affordance is marked, never omitted (the sub-tab rule, app.js above). + * Until one is named, the investigator stands as interim owner -- offered + * as a one-click act by the signed-in person, not inferred from a blank. */ +function ownerNoteHtml(field) { + const me = (state.me && (state.me.user_id || state.me.username || state.me.egeria_user)) || ''; + const offer = !field?.value && me + ? ` · ` + : ''; + return `
    owner candidates + from contributor data · not built in /next${offer}
    `; +} + +/** A fact a survey already established, offered to confirm — not applied. + * Gated on a CLASSIFIED licence: "No license detected on this repository." + * is a measured finding too, and offering it to confirm would write that + * sentence into the licence field. The tier finding's label says which. */ function proposedFrom(analysisId) { const f = state.enrichmentFacts?.[analysisId]; if (!f || f.state !== 'measured') return null; + const tier = (f.value?.findings || []).find((x) => x.check_name === 'license_risk_tier'); + // `none` is both "no licence" and "nothing examined"; `unknown` is a + // licence that IS present and unclassified -- its name is still a fact. + if (!tier || !tier.label || String(tier.label) === 'none') return null; // The licence itself, not its risk tier: the finding's label is // "permissive" and its summary is "Apache License 2.0 — Permissive". The // part before the dash is the fact a person would confirm. - const raw = f.headline || (f.value?.findings || [])[0]?.summary || ''; + const raw = tier.summary || f.headline || ''; + if (!raw.includes(' — ')) return null; const value = String(raw).split(' — ')[0].trim(); return value ? { value } : null; } @@ -4935,11 +4974,12 @@ async function renderEnrichment(slug) { if (slug !== state.selectedSlug) return; const setJ = JUDGEMENTS.filter((d) => state.enrichment?.[d.key]?.value).length; + const me = (state.me && (state.me.user_id || state.me.username || state.me.egeria_user)) || ''; host.innerHTML = `

    Nothing here is written to the catalogue until you catalogue it (Curate). What you set here is testimony — yours, dated — and the surveys' findings in the rail are material to read, not answers to accept.

    - What we judge + What we judge ${setJ} of ${JUDGEMENTS.length} set · perishable
    ${JUDGEMENTS.map((d) => fieldRowHtml(d, 'judgement')).join('')} @@ -4959,7 +4999,9 @@ async function renderEnrichment(slug) { try { const out = await saveEnrichmentField(slug, key, { value, kind, evidence: kind === 'judgement' ? evidenceSnapshot() : {}, - interim: key === 'owner' && !value, + // Interim means "the investigator stands in", which is a person + // naming themself -- not a blank. A blank owner is no owner. + interim: key === 'owner' && !!value && value === me, source: kind === 'observation' ? 'user' : '', }); state.enrichment = { ...(state.enrichment || {}), [key]: out.field }; @@ -4969,6 +5011,19 @@ async function renderEnrichment(slug) { b.textContent = err.status === 401 ? 'sign in to record' : `not saved: ${err.message}`; } })); + host.querySelectorAll('[data-owner-interim]').forEach((b) => b.addEventListener('click', async () => { + b.disabled = true; b.textContent = 'recording…'; + try { + const out = await saveEnrichmentField(slug, 'owner', { + value: b.dataset.ownerInterim, kind: 'judgement', evidence: evidenceSnapshot(), interim: true, + }); + state.enrichment = { ...(state.enrichment || {}), owner: out.field }; + renderEnrichment(slug); + } catch (err) { + b.disabled = false; + b.textContent = err.status === 401 ? 'sign in to record' : `not recorded: ${err.message}`; + } + })); host.querySelectorAll('[data-confirm]').forEach((b) => b.addEventListener('click', async () => { b.disabled = true; b.textContent = 'confirming…'; try { @@ -4992,23 +5047,26 @@ async function renderEnrichment(slug) { function renderEnrichmentEvidence(slug) { const out = $('rail-evidence'); if (!out) return; + // Rail state is a persisted preference; "new since you judged" written + // into a closed drawer is the perishability signal nobody sees. + if (!railIsOpen()) setRailOpen(true); const judged = Object.values(state.enrichment || {}).filter((f) => f.kind === 'judgement' && f.set_at); const items = ENRICHMENT_EVIDENCE.map((id) => state.enrichmentFacts?.[id]).filter(Boolean); out.innerHTML = `
    - Evidence - material, not proposals + Evidence · enrichment + for ${esc(slug)} · material, not proposals
    ${items.length ? items.map((f) => { const g = factGlyph(f.state); const seen = judged.some((j) => j.evidence?.[f.analysis_id]); - const fresh = judged.some((j) => j.evidence?.[f.analysis_id] && f.last_run_at > j.evidence[f.analysis_id]); + const fresh = judged.some((j) => j.evidence?.[f.analysis_id] && whenMs(f.last_run_at) > whenMs(j.evidence[f.analysis_id])); return `
    ${g.glyph} ${tnum(esc(f.headline || f.state))}
    ${esc(f.analysis_id)}${ - f.last_run_at ? ` · ${esc(ago(f.last_run_at))}` : ''}${ + f.last_run_at ? ` · ${esc(ago(f.last_run_at))}` : ''}${ fresh ? ` · new since you judged` : seen ? '' : ''}
    `; }).join('') : `
    No measurements to show yet.
    `} diff --git a/packages/resource-explorer/resource_explorer/web/static/next/format.js b/packages/resource-explorer/resource_explorer/web/static/next/format.js index 5d499b691..5c2f45f3c 100644 --- a/packages/resource-explorer/resource_explorer/web/static/next/format.js +++ b/packages/resource-explorer/resource_explorer/web/static/next/format.js @@ -6,9 +6,22 @@ /** "5d ago" / "3h ago" / "just now" — or "" when there is no timestamp, * which is a real state and must not render as "now". */ +/** Milliseconds for a registry timestamp, or NaN. The registry writes + * three spellings -- `...Z`, `...+00:00`, and NAIVE (`datetime.utcnow()` + * with no zone) -- and Date.parse reads a naive ISO string as LOCAL time, + * so a naive UTC stamp compared with a zoned one is off by the zone + * offset in whichever direction the viewer sits. Every comparison and + * every age goes through here; a naive stamp is UTC. */ +export function whenMs(iso) { + if (!iso) return NaN; + const s = String(iso); + const zoned = /(Z|[+-]\d\d:?\d\d)$/.test(s); + return Date.parse(zoned ? s : `${s}Z`); +} + export function ago(iso) { if (!iso) return ''; - const then = Date.parse(iso); + const then = whenMs(iso); if (Number.isNaN(then)) return ''; const secs = Math.max(0, (Date.now() - then) / 1000); if (secs < 90) return 'just now'; @@ -23,7 +36,7 @@ export function ago(iso) { * this to decide staleness; `ago` is for showing it. */ export function daysSince(iso) { if (!iso) return null; - const then = Date.parse(iso); + const then = whenMs(iso); if (Number.isNaN(then)) return null; return (Date.now() - then) / 86400000; } diff --git a/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css b/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css index 55d4ed7b8..da0274226 100644 --- a/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css +++ b/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SF Mono,Menlo,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.z-40{z-index:40}.z-50{z-index:50}.z-\[90\]{z-index:90}.m-0{margin:0}.my-s3{margin-top:13.8px;margin-bottom:13.8px}.-ml-\[22px\]{margin-left:-22px}.mb-\[2px\]{margin-bottom:2px}.mb-\[3px\]{margin-bottom:3px}.mb-\[4px\]{margin-bottom:4px}.mb-\[6px\]{margin-bottom:6px}.mb-\[7px\]{margin-bottom:7px}.mb-s1{margin-bottom:4.6px}.mb-s2{margin-bottom:9.2px}.mb-s3{margin-bottom:13.8px}.mb-s4{margin-bottom:18.4px}.ml-\[22px\]{margin-left:22px}.ml-auto{margin-left:auto}.ml-s2{margin-left:9.2px}.ml-s3{margin-left:13.8px}.mr-1{margin-right:.25rem}.mr-s3{margin-right:13.8px}.mt-\[10px\]{margin-top:10px}.mt-\[2px\]{margin-top:2px}.mt-\[3px\]{margin-top:3px}.mt-\[4px\]{margin-top:4px}.mt-\[5px\]{margin-top:5px}.mt-\[6px\]{margin-top:6px}.mt-\[6vh\]{margin-top:6vh}.mt-\[7px\]{margin-top:7px}.mt-s1{margin-top:4.6px}.mt-s2{margin-top:9.2px}.mt-s3{margin-top:13.8px}.mt-s4{margin-top:18.4px}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.\!grid{display:grid!important}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-\[14px\]{height:14px}.h-\[9px\]{height:9px}.h-full{height:100%}.h-px{height:1px}.min-h-0{min-height:0}.min-h-\[320px\]{min-height:320px}.w-\[12ch\]{width:12ch}.w-\[13px\]{width:13px}.w-\[160px\]{width:160px}.w-\[16px\]{width:16px}.w-\[18px\]{width:18px}.w-\[22px\]{width:22px}.w-\[26px\]{width:26px}.w-\[34px\]{width:34px}.w-\[42\%\]{width:42%}.w-\[min\(380px\2c 92vw\)\]{width:min(380px,92vw)}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[60ch\]{max-width:60ch}.max-w-\[640px\]{max-width:640px}.max-w-\[70ch\]{max-width:70ch}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize-y{resize:vertical}.resize{resize:both}.scroll-mt-\[8px\]{scroll-margin-top:8px}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.grid-cols-\[130px_1fr\]{grid-template-columns:130px 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.items-start{align-items:flex-start}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-\[1px\]{gap:1px}.gap-\[2px\]{gap:2px}.gap-\[4px\]{gap:4px}.gap-\[5px\]{gap:5px}.gap-\[6px\]{gap:6px}.gap-\[9px\]{gap:9px}.gap-s1{gap:4.6px}.gap-s2{gap:9.2px}.gap-s3{gap:13.8px}.gap-s4{gap:18.4px}.gap-x-s2{-moz-column-gap:9.2px;column-gap:9.2px}.gap-x-s3{-moz-column-gap:13.8px;column-gap:13.8px}.gap-y-\[2px\]{row-gap:2px}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-\[2px\]{border-radius:2px}.rounded-pill{border-radius:11px}.rounded-sm{border-radius:2px}.border{border-width:1px}.border-0{border-width:0}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l{border-left-width:1px}.border-l-2{border-left-width:2px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-accent{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.border-accent-deep{--tw-border-opacity:1;border-color:rgb(90 59 10/var(--tw-border-opacity,1))}.border-chrome-line{--tw-border-opacity:1;border-color:rgb(68 65 65/var(--tw-border-opacity,1))}.border-chrome-line-soft{--tw-border-opacity:1;border-color:rgb(45 43 43/var(--tw-border-opacity,1))}.border-chrome-muted{--tw-border-opacity:1;border-color:rgb(155 151 151/var(--tw-border-opacity,1))}.border-rule{border-color:rgba(32,31,29,.16)}.border-rule-strong{--tw-border-opacity:1;border-color:rgb(142 138 138/var(--tw-border-opacity,1))}.border-state-gap{--tw-border-opacity:1;border-color:rgb(91 74 156/var(--tw-border-opacity,1))}.border-state-warn{--tw-border-opacity:1;border-color:rgb(156 66 18/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.bg-\[rgba\(168\2c 113\2c 42\2c \.07\)\]{background-color:rgba(168,113,42,.07)}.bg-\[rgba\(32\2c 31\2c 29\2c \.03\)\]{background-color:rgba(32,31,29,.03)}.bg-black\/40{background-color:rgba(0,0,0,.4)}.bg-chrome{--tw-bg-opacity:1;background-color:rgb(27 26 25/var(--tw-bg-opacity,1))}.bg-chrome-alt{--tw-bg-opacity:1;background-color:rgb(32 31 29/var(--tw-bg-opacity,1))}.bg-chrome-surface{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.bg-paper{--tw-bg-opacity:1;background-color:rgb(243 242 242/var(--tw-bg-opacity,1))}.bg-paper-surface{--tw-bg-opacity:1;background-color:rgb(234 233 233/var(--tw-bg-opacity,1))}.bg-rule{background-color:rgba(32,31,29,.16)}.bg-transparent{background-color:transparent}.p-0{padding:0}.p-\[6px\]{padding:6px}.p-s2{padding:9.2px}.p-s3{padding:13.8px}.p-s4{padding:18.4px}.px-0{padding-left:0;padding-right:0}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-\[10px\]{padding-left:10px;padding-right:10px}.px-\[1px\]{padding-left:1px;padding-right:1px}.px-\[4px\]{padding-left:4px;padding-right:4px}.px-\[5px\]{padding-left:5px;padding-right:5px}.px-\[6px\]{padding-left:6px;padding-right:6px}.px-\[9px\]{padding-left:9px;padding-right:9px}.px-s2{padding-left:9.2px;padding-right:9.2px}.px-s3{padding-left:13.8px;padding-right:13.8px}.py-\[10px\]{padding-top:10px;padding-bottom:10px}.py-\[1px\]{padding-top:1px;padding-bottom:1px}.py-\[2px\]{padding-top:2px;padding-bottom:2px}.py-\[3px\]{padding-top:3px;padding-bottom:3px}.py-\[4px\]{padding-top:4px;padding-bottom:4px}.py-\[5px\]{padding-top:5px;padding-bottom:5px}.py-\[6px\]{padding-top:6px;padding-bottom:6px}.py-\[7px\]{padding-top:7px;padding-bottom:7px}.py-\[9px\]{padding-top:9px;padding-bottom:9px}.py-s2{padding-top:9.2px;padding-bottom:9.2px}.py-s3{padding-top:13.8px;padding-bottom:13.8px}.py-s4{padding-top:18.4px;padding-bottom:18.4px}.pb-\[1px\]{padding-bottom:1px}.pb-\[2px\]{padding-bottom:2px}.pb-\[4px\]{padding-bottom:4px}.pb-s1{padding-bottom:4.6px}.pb-s2{padding-bottom:9.2px}.pb-s3{padding-bottom:13.8px}.pl-0{padding-left:0}.pl-\[20px\]{padding-left:20px}.pl-\[22px\]{padding-left:22px}.pl-s2{padding-left:9.2px}.pl-s3{padding-left:13.8px}.pr-s3{padding-right:13.8px}.pt-\[9px\]{padding-top:9px}.pt-s1{padding-top:4.6px}.pt-s2{padding-top:9.2px}.pt-s3{padding-top:13.8px}.text-left{text-align:left}.text-right{text-align:right}.align-bottom{vertical-align:bottom}.align-\[-2px\]{vertical-align:-2px}.font-body{font-family:Lora,Georgia,serif}.font-diagram{font-family:ui-monospace,SF Mono,Menlo,monospace}.font-heading{font-family:Cormorant Garamond,Georgia,serif}.font-mono{font-family:ui-monospace,SF Mono,Menlo,monospace}.text-\[1\.1rem\]{font-size:1.1rem}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.text-\[17px\]{font-size:17px}.text-answer{font-size:.9375rem;line-height:1.55}.text-brand{font-size:1.1875rem;line-height:1.2}.text-caps{font-size:.75rem;line-height:1.3}.text-caveat{font-size:.9375rem;line-height:1.55}.text-chip{font-size:.8125rem;line-height:1.3}.text-intent{font-size:.875rem;line-height:1.2}.text-name{font-size:1.625rem;line-height:1.15}.text-provenance{font-size:.8125rem;line-height:1.45}.text-question{font-size:1.0625rem;line-height:1.25}.text-resource{font-size:.8125rem;line-height:1.35}.text-subtab{font-size:.875rem;line-height:1.2}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.leading-\[1\.95\]{line-height:1.95}.leading-none{line-height:1}.tracking-caps{letter-spacing:.09em}.tracking-normal{letter-spacing:0}.text-accent{--tw-text-opacity:1;color:rgb(182 130 53/var(--tw-text-opacity,1))}.text-accent-ink{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.text-accent-on-dark{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.text-chrome-ink{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.text-chrome-muted{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.text-ink{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.text-ink-muted{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.text-rule-strong{--tw-text-opacity:1;color:rgb(142 138 138/var(--tw-text-opacity,1))}.text-state-gap{--tw-text-opacity:1;color:rgb(91 74 156/var(--tw-text-opacity,1))}.text-state-gap-on-dark{--tw-text-opacity:1;color:rgb(169 154 224/var(--tw-text-opacity,1))}.text-state-ok{--tw-text-opacity:1;color:rgb(29 107 63/var(--tw-text-opacity,1))}.text-state-ok-on-dark{--tw-text-opacity:1;color:rgb(116 198 141/var(--tw-text-opacity,1))}.text-state-warn{--tw-text-opacity:1;color:rgb(156 66 18/var(--tw-text-opacity,1))}.text-state-warn-on-dark{--tw-text-opacity:1;color:rgb(242 162 106/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-dotted{text-decoration-style:dotted}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.accent-accent{accent-color:#b68235}.opacity-100{opacity:1}.shadow-lg{--tw-shadow:0 12px 32px rgba(45,43,43,.22);--tw-shadow-colored:0 12px 32px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.placeholder\:text-chrome-muted::-moz-placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.placeholder\:text-chrome-muted::placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.placeholder\:text-ink-muted::-moz-placeholder{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.placeholder\:text-ink-muted::placeholder{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.last\:border-0:last-child{border-width:0}.hover\:border-accent:hover{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.hover\:bg-\[rgba\(32\2c 31\2c 29\2c \.05\)\]:hover{background-color:rgba(32,31,29,.05)}.hover\:bg-accent-tint:hover{--tw-bg-opacity:1;background-color:rgb(255 243 228/var(--tw-bg-opacity,1))}.hover\:bg-chrome-surface:hover{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.hover\:text-accent-ink:hover{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.hover\:text-accent-on-dark:hover{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.hover\:text-chrome-ink:hover{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.hover\:text-ink:hover{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.focus-visible\:text-chrome-ink:focus-visible{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SF Mono,Menlo,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.z-40{z-index:40}.z-50{z-index:50}.z-\[90\]{z-index:90}.m-0{margin:0}.my-s3{margin-top:13.8px;margin-bottom:13.8px}.-ml-\[22px\]{margin-left:-22px}.mb-\[2px\]{margin-bottom:2px}.mb-\[3px\]{margin-bottom:3px}.mb-\[4px\]{margin-bottom:4px}.mb-\[6px\]{margin-bottom:6px}.mb-\[7px\]{margin-bottom:7px}.mb-s1{margin-bottom:4.6px}.mb-s2{margin-bottom:9.2px}.mb-s3{margin-bottom:13.8px}.mb-s4{margin-bottom:18.4px}.ml-\[22px\]{margin-left:22px}.ml-auto{margin-left:auto}.ml-s2{margin-left:9.2px}.ml-s3{margin-left:13.8px}.mr-1{margin-right:.25rem}.mr-s3{margin-right:13.8px}.mt-\[10px\]{margin-top:10px}.mt-\[2px\]{margin-top:2px}.mt-\[3px\]{margin-top:3px}.mt-\[4px\]{margin-top:4px}.mt-\[5px\]{margin-top:5px}.mt-\[6px\]{margin-top:6px}.mt-\[6vh\]{margin-top:6vh}.mt-\[7px\]{margin-top:7px}.mt-s1{margin-top:4.6px}.mt-s2{margin-top:9.2px}.mt-s3{margin-top:13.8px}.mt-s4{margin-top:18.4px}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.\!grid{display:grid!important}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-\[14px\]{height:14px}.h-\[9px\]{height:9px}.h-full{height:100%}.h-px{height:1px}.min-h-0{min-height:0}.min-h-\[320px\]{min-height:320px}.w-\[12ch\]{width:12ch}.w-\[13px\]{width:13px}.w-\[160px\]{width:160px}.w-\[16px\]{width:16px}.w-\[18px\]{width:18px}.w-\[22px\]{width:22px}.w-\[26px\]{width:26px}.w-\[34px\]{width:34px}.w-\[42\%\]{width:42%}.w-\[min\(380px\2c 92vw\)\]{width:min(380px,92vw)}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[60ch\]{max-width:60ch}.max-w-\[640px\]{max-width:640px}.max-w-\[70ch\]{max-width:70ch}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize-y{resize:vertical}.resize{resize:both}.scroll-mt-\[8px\]{scroll-margin-top:8px}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.grid-cols-\[130px_1fr\]{grid-template-columns:130px 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.items-start{align-items:flex-start}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-\[1px\]{gap:1px}.gap-\[2px\]{gap:2px}.gap-\[4px\]{gap:4px}.gap-\[5px\]{gap:5px}.gap-\[6px\]{gap:6px}.gap-\[9px\]{gap:9px}.gap-s1{gap:4.6px}.gap-s2{gap:9.2px}.gap-s3{gap:13.8px}.gap-s4{gap:18.4px}.gap-x-s2{-moz-column-gap:9.2px;column-gap:9.2px}.gap-x-s3{-moz-column-gap:13.8px;column-gap:13.8px}.gap-y-\[2px\]{row-gap:2px}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-\[2px\]{border-radius:2px}.rounded-pill{border-radius:11px}.rounded-sm{border-radius:2px}.border{border-width:1px}.border-0{border-width:0}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l{border-left-width:1px}.border-l-2{border-left-width:2px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-accent{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.border-accent-deep{--tw-border-opacity:1;border-color:rgb(90 59 10/var(--tw-border-opacity,1))}.border-chrome-line{--tw-border-opacity:1;border-color:rgb(68 65 65/var(--tw-border-opacity,1))}.border-chrome-line-soft{--tw-border-opacity:1;border-color:rgb(45 43 43/var(--tw-border-opacity,1))}.border-chrome-muted{--tw-border-opacity:1;border-color:rgb(155 151 151/var(--tw-border-opacity,1))}.border-current{border-color:currentColor}.border-rule{border-color:rgba(32,31,29,.16)}.border-rule-strong{--tw-border-opacity:1;border-color:rgb(142 138 138/var(--tw-border-opacity,1))}.border-state-gap{--tw-border-opacity:1;border-color:rgb(91 74 156/var(--tw-border-opacity,1))}.border-state-warn{--tw-border-opacity:1;border-color:rgb(156 66 18/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.bg-\[rgba\(168\2c 113\2c 42\2c \.07\)\]{background-color:rgba(168,113,42,.07)}.bg-\[rgba\(32\2c 31\2c 29\2c \.03\)\]{background-color:rgba(32,31,29,.03)}.bg-black\/40{background-color:rgba(0,0,0,.4)}.bg-chrome{--tw-bg-opacity:1;background-color:rgb(27 26 25/var(--tw-bg-opacity,1))}.bg-chrome-alt{--tw-bg-opacity:1;background-color:rgb(32 31 29/var(--tw-bg-opacity,1))}.bg-chrome-surface{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.bg-paper{--tw-bg-opacity:1;background-color:rgb(243 242 242/var(--tw-bg-opacity,1))}.bg-paper-surface{--tw-bg-opacity:1;background-color:rgb(234 233 233/var(--tw-bg-opacity,1))}.bg-rule{background-color:rgba(32,31,29,.16)}.bg-transparent{background-color:transparent}.p-0{padding:0}.p-\[6px\]{padding:6px}.p-s2{padding:9.2px}.p-s3{padding:13.8px}.p-s4{padding:18.4px}.px-0{padding-left:0;padding-right:0}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-\[10px\]{padding-left:10px;padding-right:10px}.px-\[1px\]{padding-left:1px;padding-right:1px}.px-\[4px\]{padding-left:4px;padding-right:4px}.px-\[5px\]{padding-left:5px;padding-right:5px}.px-\[6px\]{padding-left:6px;padding-right:6px}.px-\[9px\]{padding-left:9px;padding-right:9px}.px-s2{padding-left:9.2px;padding-right:9.2px}.px-s3{padding-left:13.8px;padding-right:13.8px}.py-\[10px\]{padding-top:10px;padding-bottom:10px}.py-\[1px\]{padding-top:1px;padding-bottom:1px}.py-\[2px\]{padding-top:2px;padding-bottom:2px}.py-\[3px\]{padding-top:3px;padding-bottom:3px}.py-\[4px\]{padding-top:4px;padding-bottom:4px}.py-\[5px\]{padding-top:5px;padding-bottom:5px}.py-\[6px\]{padding-top:6px;padding-bottom:6px}.py-\[7px\]{padding-top:7px;padding-bottom:7px}.py-\[9px\]{padding-top:9px;padding-bottom:9px}.py-s2{padding-top:9.2px;padding-bottom:9.2px}.py-s3{padding-top:13.8px;padding-bottom:13.8px}.py-s4{padding-top:18.4px;padding-bottom:18.4px}.pb-\[1px\]{padding-bottom:1px}.pb-\[2px\]{padding-bottom:2px}.pb-\[4px\]{padding-bottom:4px}.pb-s1{padding-bottom:4.6px}.pb-s2{padding-bottom:9.2px}.pb-s3{padding-bottom:13.8px}.pl-0{padding-left:0}.pl-\[20px\]{padding-left:20px}.pl-\[22px\]{padding-left:22px}.pl-s2{padding-left:9.2px}.pl-s3{padding-left:13.8px}.pr-s3{padding-right:13.8px}.pt-\[9px\]{padding-top:9px}.pt-s1{padding-top:4.6px}.pt-s2{padding-top:9.2px}.pt-s3{padding-top:13.8px}.text-left{text-align:left}.text-right{text-align:right}.align-bottom{vertical-align:bottom}.align-\[-2px\]{vertical-align:-2px}.font-body{font-family:Lora,Georgia,serif}.font-diagram{font-family:ui-monospace,SF Mono,Menlo,monospace}.font-heading{font-family:Cormorant Garamond,Georgia,serif}.font-mono{font-family:ui-monospace,SF Mono,Menlo,monospace}.text-\[1\.1rem\]{font-size:1.1rem}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.text-\[17px\]{font-size:17px}.text-answer{font-size:.9375rem;line-height:1.55}.text-brand{font-size:1.1875rem;line-height:1.2}.text-caps{font-size:.75rem;line-height:1.3}.text-caveat{font-size:.9375rem;line-height:1.55}.text-chip{font-size:.8125rem;line-height:1.3}.text-intent{font-size:.875rem;line-height:1.2}.text-name{font-size:1.625rem;line-height:1.15}.text-provenance{font-size:.8125rem;line-height:1.45}.text-question{font-size:1.0625rem;line-height:1.25}.text-resource{font-size:.8125rem;line-height:1.35}.text-subtab{font-size:.875rem;line-height:1.2}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.leading-\[1\.95\]{line-height:1.95}.leading-none{line-height:1}.tracking-caps{letter-spacing:.09em}.tracking-normal{letter-spacing:0}.text-accent{--tw-text-opacity:1;color:rgb(182 130 53/var(--tw-text-opacity,1))}.text-accent-ink{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.text-accent-on-dark{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.text-chrome-ink{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.text-chrome-muted{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.text-ink{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.text-ink-muted{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.text-rule-strong{--tw-text-opacity:1;color:rgb(142 138 138/var(--tw-text-opacity,1))}.text-state-gap{--tw-text-opacity:1;color:rgb(91 74 156/var(--tw-text-opacity,1))}.text-state-gap-on-dark{--tw-text-opacity:1;color:rgb(169 154 224/var(--tw-text-opacity,1))}.text-state-ok{--tw-text-opacity:1;color:rgb(29 107 63/var(--tw-text-opacity,1))}.text-state-ok-on-dark{--tw-text-opacity:1;color:rgb(116 198 141/var(--tw-text-opacity,1))}.text-state-warn{--tw-text-opacity:1;color:rgb(156 66 18/var(--tw-text-opacity,1))}.text-state-warn-on-dark{--tw-text-opacity:1;color:rgb(242 162 106/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-dotted{text-decoration-style:dotted}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.accent-accent{accent-color:#b68235}.opacity-100{opacity:1}.shadow-lg{--tw-shadow:0 12px 32px rgba(45,43,43,.22);--tw-shadow-colored:0 12px 32px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.placeholder\:text-chrome-muted::-moz-placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.placeholder\:text-chrome-muted::placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.placeholder\:text-ink-muted::-moz-placeholder{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.placeholder\:text-ink-muted::placeholder{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.last\:border-0:last-child{border-width:0}.hover\:border-accent:hover{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.hover\:bg-\[rgba\(32\2c 31\2c 29\2c \.05\)\]:hover{background-color:rgba(32,31,29,.05)}.hover\:bg-accent-tint:hover{--tw-bg-opacity:1;background-color:rgb(255 243 228/var(--tw-bg-opacity,1))}.hover\:bg-chrome-surface:hover{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.hover\:text-accent-ink:hover{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.hover\:text-accent-on-dark:hover{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.hover\:text-chrome-ink:hover{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.hover\:text-ink:hover{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.focus-visible\:text-chrome-ink:focus-visible{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))} \ No newline at end of file diff --git a/packages/resource-explorer/tests/test_next_enrichment_fidelity.py b/packages/resource-explorer/tests/test_next_enrichment_fidelity.py new file mode 100644 index 000000000..d2edcc89a --- /dev/null +++ b/packages/resource-explorer/tests/test_next_enrichment_fidelity.py @@ -0,0 +1,88 @@ +"""Pins from the designer's 2026-09-12 review of the Enrichment pane. + +Two things a Python suite can hold the /next shell to without a browser: +that every human-supplied catalog question reaches the Enrichment stage, +and that the timestamp comparisons behind "evidence moved" survive the +three spellings the registry writes. +""" +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +NEXT = Path(__file__).resolve().parents[1] / "resource_explorer" / "web" / "static" / "next" + + +class TestEveryHumanQuestionReachesEnrichment: + """The pane's heading says "the catalog's own questions for a person, + below". Two of the seven were stage `Analysis` only, so exact phase + matching kept them off the pane that promised them.""" + + def test_all_human_supplied_rows_are_in_the_enrichment_stage(self): + from resource_explorer.surveyors.question_catalog_reader import get_questions + human = [q for q in get_questions("repo") if (q.get("answering") or {}).get("kind") == "human"] + assert human, "no human-supplied rows at all -- the catalog changed shape" + enrichment = {q["question"] for q in get_questions("repo", phase="Enrichment")} + missing = [q["question"] for q in human if q["question"] not in enrichment] + assert missing == [], f"human-supplied questions a person cannot reach on the Enrichment pane: {missing}" + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not installed") +class TestTimestampSpellings: + """`movedSince` and the rail's `fresh` compared raw ISO strings. The + registry writes `Z`, `+00:00` and NAIVE stamps (datetime.utcnow()), and + Date.parse reads a naive one as local time, so the flag fired or failed + on the suffix. Every comparison now goes through `whenMs`, which reads a + naive stamp as UTC.""" + + def _run(self, expr: str, tmp_path: Path): + # The shell's modules are .js with no package.json, which node reads + # as CommonJS; a .mjs copy makes the import unambiguous on any node. + mod = tmp_path / "format.mjs" + mod.write_text((NEXT / "format.js").read_text(encoding="utf-8"), encoding="utf-8") + script = f"import {{ whenMs, ago }} from '{mod.as_uri()}';\nconsole.log(JSON.stringify({expr}));" + out = subprocess.run(["node", "--input-type=module", "-e", script], + capture_output=True, text=True, check=True) + return json.loads(out.stdout) + + def test_the_three_spellings_of_one_instant_are_equal(self, tmp_path): + z, plus, naive = self._run( + "[whenMs('2026-09-12T01:57:55.332303Z'), whenMs('2026-09-12T01:57:55.332303+00:00'), " + "whenMs('2026-09-12T01:57:55.332303')]", tmp_path) + assert z == plus == naive + + def test_a_later_naive_stamp_is_later_than_an_earlier_zoned_one(self, tmp_path): + # 10s apart; a string compare would put the zoned one "later" because + # '+' sorts above the digits, and a local-time parse could go either way. + assert self._run("whenMs('2026-09-12T01:58:05') > whenMs('2026-09-12T01:57:55+00:00')", tmp_path) is True + + def test_no_raw_string_comparison_of_last_run_at_survives(self): + src = (NEXT / "app.js").read_text(encoding="utf-8") + import re + raw = re.findall(r"last_run_at\s*[<>]\s*(?!whenMs)", src) + re.findall(r"(? at\b", src) + assert raw == [], f"timestamp compared as a string, not an instant: {raw}" + + def test_all_shell_modules_parse(self, tmp_path): + for f in ("app.js", "worklist.js", "format.js", "feedback.js"): + mod = tmp_path / f.replace(".js", ".mjs") + mod.write_text((NEXT / f).read_text(encoding="utf-8"), encoding="utf-8") + r = subprocess.run(["node", "--check", str(mod)], capture_output=True, text=True) + assert r.returncode == 0, f"{f}: {r.stderr[:400]}" + + +class TestTheLicenceConfirmIsGated: + """`proposedFrom` offered whatever the headline said, and "No license + detected on this repository." is a measured finding with a headline, so + the row offered that sentence to confirm into the licence field. The + offer now keys on the `license_risk_tier` finding's label: `none` (no + licence, or nothing examined) offers nothing.""" + + def test_the_offer_reads_the_tier_finding_not_the_headline(self): + src = (NEXT / "app.js").read_text(encoding="utf-8") + body = src[src.index("function proposedFrom("):src.index("async function renderEnrichment(")] + assert "license_risk_tier" in body and "'none'" in body + assert "f.headline ||" not in body.split("const raw")[0], "the gate must run before the headline is read" From e3ffab388a80d0971cd3ffad9b57c178d016c2eb Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sat, 12 Sep 2026 15:01:48 -0500 Subject: [PATCH 23/37] =?UTF-8?q?RE:=20dependency=5Fsupport=20=E2=80=94=20?= =?UTF-8?q?which=20technologies=20a=20repo's=20dependencies=20indicate,=20?= =?UTF-8?q?as=20a=20starting=20point=20for=20people?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first of the coverage audit's gap questions to get an analytic. Two project-owner directions shape it: Egeria may hold "a starting point that would still need corroboration and augmentation by people" (2026-09-11), and "A now, shaped to seed B" (2026-09-12) - a curated mapping in RE, each entry linked to the Egeria technology type it will later promote into. Measured before building, and the measurement changed the design. Against the 2,894 distinct dependency names in the registry, Egeria's 213 technology types matched TWO by exact name; token overlap matched 373, nearly all wrong - apache_atlas to Apache Airflow via "apache", @babel/plugin-proposal-function- bind to "Unity Catalog Function". Both of Egeria's candidate sources are overwhelmingly Egeria describing itself; the "catalog of already-supported dependencies" the question catalog assumed does not yet exist. psycopg2 to PostgreSQL is knowledge, not similarity, so the knowledge is a file people curate: configdata/dependency_support.yaml, 20 technologies, strict loader - unknown keys, duplicates and any pattern under three characters are errors, because a two-letter pattern is exactly how "apache" matched Airflow 105 times. Three states, kept apart on purpose. A dependency that MATCHED - this repo indicates X. One that matched NOTHING - no curated technology corresponds to this name, which is almost always "nobody has classified it yet" and never "unsupported". And a technology whose Egeria type could NOT BE CHECKED - Egeria unreachable, which is not the same as absent. The coverage row carries the Egeria check state at the top. Unmatched names are re-derived at read time against today's mapping rather than stored, so a stale "unclassified" cannot outlive the curation that classified it, and the cross-repo ranked queue - the actual output people act on - needs no table of its own. The question "Do we already support these dependencies?" moves from human to mixed: machine-informed, human-confirmed. The step is Discovery tier - it reads project_dependencies and fetches nothing from the repository; the one Egeria call is catalog consultation. Added to RepoDiscoverySurvey, and to the Discovery allowlist in test_analysis_catalog_reader with the reason. Verified live on egeria_python_git: 32 dependencies, PostgreSQL / Jupyter / pyegeria indicated and all three present in Egeria (211 types checked), 29 unclassified and listed. Across 51 repos with dependency data: PostgreSQL on 16, Jupyter and Redis on 10, Docker and Kubernetes on 9. The top of the curation queue - pydantic (27 repos), pandas (23), requests (23), pytest (22) - are libraries rather than technologies, which is a curation decision the mapping can express either way and is recorded as open. Two faults found by running it rather than reading it. The results reader first read a `detail` key where query_findings hands back `detail_json` as a string, so every technology came back unchecked with no dependencies; only the live read-back showed it, and a test now pins the parse. And the adapter used `log` at three STEP-introspection warning sites without ever defining it - three latent NameErrors since they were written; adding a fourth use surfaced all four under ruff, and one module-level line fixes them. 33 new tests. Five guards made to fail on purpose: Egeria-unreachable reading as absent, the three-character floor, a greedy apache* pattern in the seed, the detail_json parse, and the no-dependencies path reporting a normal zero. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- packages/resource-explorer/docs/Backlog.md | 70 ++++ .../docs/dr-egeria/repo_survey_types.csv | 1 + .../survey-definitions/.generated.json | 4 +- .../repo-survey-definition-discovery.md | 40 +++ .../repo-survey-definition-full.md | 40 +++ .../configdata/analysis_catalog.yaml | 17 + .../configdata/dependency_support.yaml | 233 ++++++++++++ .../configdata/question_catalog.yaml | 13 +- .../surveyors/dependency_support.py | 274 ++++++++++++++ .../repo_survey_definition_adapter.py | 121 +++++++ .../surveyors/sub_surveyors/__init__.py | 2 + .../sub_surveyors/dependency_support.py | 116 ++++++ .../resource_explorer/web/static/index.html | 70 ++++ .../tests/test_analysis_catalog_reader.py | 9 +- .../tests/test_dependency_support.py | 334 ++++++++++++++++++ 15 files changed, 1338 insertions(+), 6 deletions(-) create mode 100644 packages/resource-explorer/resource_explorer/configdata/dependency_support.yaml create mode 100644 packages/resource-explorer/resource_explorer/surveyors/dependency_support.py create mode 100644 packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/dependency_support.py create mode 100644 packages/resource-explorer/tests/test_dependency_support.py diff --git a/packages/resource-explorer/docs/Backlog.md b/packages/resource-explorer/docs/Backlog.md index 389cd853a..aae0c416d 100644 --- a/packages/resource-explorer/docs/Backlog.md +++ b/packages/resource-explorer/docs/Backlog.md @@ -4554,6 +4554,76 @@ token counts as RE's. collector went from `['default']` to `['default', 'resource-explorer']`. Nobody's history is deleted; the two simply stop sharing a bucket. +## "Do we already support these dependencies?" — `dependency_support`, BUILT 2026-09-12 + +The first of the coverage audit's three gap questions to get an analytic, on +the project owner's direction: Egeria may hold "a starting point that would +still need corroboration and augmentation by people" (2026-09-11), and **"A +now, shaped to seed B"** (2026-09-12) — a curated mapping in RE, each entry +linked to the Egeria technology type it will later promote into. + +**The measurement that shaped it.** Before building, dependency names were +matched against Egeria's technology-type catalog directly. 2,894 distinct +names, 213 types: **2** exact matches; **373** by token overlap, nearly all +wrong (`apache_atlas` → Apache Airflow via "apache", `@babel/plugin-proposal- +function-bind` → "Unity Catalog Function", `antlr4-python3-runtime` → +"Runtime Manager API"). Both of Egeria's candidate sources — technology types +and software capabilities — are overwhelmingly **Egeria describing itself** +(OMES, OMVS, connectors, integration groups); roughly ten are external +products. `psycopg2` ↔ PostgreSQL is knowledge, not similarity, and the +premise "Egeria's catalog of already-supported dependencies" does not yet +exist as an artifact. A string matcher would have handed people ~370 false +positives as a "starting point" — worse than nothing. + +**What was built.** `configdata/dependency_support.yaml` — 20 technologies, +each with the dependency-name patterns that indicate it and, where one exists, +the Egeria `deployedImplementationType` displayName (verified live). Strict +loader: unknown keys, duplicate names and any pattern under three characters +are errors, because a two-letter pattern is exactly how `apache` matched +Airflow 105 times. `surveyors/dependency_support.py` is the pure matcher plus a +separate Egeria check; `sub_surveyors/dependency_support.py` is the step +(`repo_dependency_support`, Discovery tier — zero repo fetch, reads +`project_dependencies`); the question moves from `human` to **`mixed`**. + +**Three states, kept apart on purpose.** A dependency that MATCHED ("this repo +indicates X"); one that matched NOTHING ("no curated technology corresponds to +this name" — almost always "nobody has classified it yet", **never +"unsupported"**); and a technology whose Egeria type COULD NOT BE CHECKED +(Egeria unreachable — not the same as absent). The coverage row carries the +Egeria check state at the top so a reader never infers it from per-row states. +Unmatched names are re-derived at read time against today's mapping rather than +stored, so a stale "unclassified" cannot outlive the curation that classified +it — and so the cross-repo ranked queue needs no table of its own. + +**Verified live on `egeria_python_git`:** 32 dependencies → PostgreSQL +(psycopg2-binary), Jupyter, pyegeria, all three present in Egeria (211 types +checked); 29 unclassified, listed. Across the 51 repos with dependency data: +PostgreSQL indicated on 16, Jupyter 10, Redis 10, Docker 9, Kubernetes 9. + +**The curation queue is the real output**, and it surfaces a decision the +mapping can express either way but someone has to make: the most-depended-on +unclassified names are `pydantic` (27 repos), `pandas` (23), `requests` (23), +`pytest` (22), `pyyaml` (20) — general-purpose **libraries**, not technologies +in Egeria's `deployedImplementationType` sense. Whether "do we support this +dependency" means Postgres/Kafka or pydantic/pandas is a curation choice. +Clear technology signals next in the queue: `boto3` (12), `openai` (12), +`sqlalchemy` (12), `transformers` (16), `torch` (12). + +**The B step this seeds.** Egeria already types `pyegeria` as a +`SoftwareLibrary` `deployedImplementationType` valid value — the natural open +metadata type for a library dependency. Promoting the mapping means creating +those valid values for the entries marked `egeria_technology_type: null` and a +"Supported Technologies" collection over them; the YAML carries everything +needed and nothing has to be redesigned. + +**Two faults found on the way, both by running it rather than reading it.** +The results reader first read a `detail` key where `query_findings` hands back +`detail_json` as a string — every technology came back `unchecked` with no +dependencies, and only the live read-back showed it. And the adapter had +**three pre-existing latent `NameError`s**: `log` was used at three STEP- +introspection warning sites and never defined; adding a fourth use surfaced all +four under ruff, and one module-level line fixed them. + ## Funnel tier vocabulary — resolved from the catalog, §2 and §4 answered 2026-09-10 **`activity_log.intent` cannot be used to tier a run.** It is stamped at write diff --git a/packages/resource-explorer/docs/dr-egeria/repo_survey_types.csv b/packages/resource-explorer/docs/dr-egeria/repo_survey_types.csv index 8cc0e58e0..e15b4251b 100644 --- a/packages/resource-explorer/docs/dr-egeria/repo_survey_types.csv +++ b/packages/resource-explorer/docs/dr-egeria/repo_survey_types.csv @@ -24,6 +24,7 @@ discovery,RepoDiscoverySurvey,Repo Discovery Survey,"Early-headlights signals fo discovery,RepoDiscoverySurvey,Repo Discovery Survey,"Early-headlights signals for deciding whether to pursue this repo further — from data already collected by Scouting/Profile, zero new fetch. Grown by Part 2 (docs/discovery-automate-project-context-plan.md) with maturity/lifecycle-stage and repo-convention signals (security policy, build automation, deployment/Docker evidence, catalog self-description, documentation breadth).",repo-survey-definition-discovery.md,repo_conventions,4 discovery,RepoDiscoverySurvey,Repo Discovery Survey,"Early-headlights signals for deciding whether to pursue this repo further — from data already collected by Scouting/Profile, zero new fetch. Grown by Part 2 (docs/discovery-automate-project-context-plan.md) with maturity/lifecycle-stage and repo-convention signals (security policy, build automation, deployment/Docker evidence, catalog self-description, documentation breadth).",repo-survey-definition-discovery.md,repo_community_support,5 discovery,RepoDiscoverySurvey,Repo Discovery Survey,"Early-headlights signals for deciding whether to pursue this repo further — from data already collected by Scouting/Profile, zero new fetch. Grown by Part 2 (docs/discovery-automate-project-context-plan.md) with maturity/lifecycle-stage and repo-convention signals (security policy, build automation, deployment/Docker evidence, catalog self-description, documentation breadth).",repo-survey-definition-discovery.md,repo_interface_surface,6 +discovery,RepoDiscoverySurvey,Repo Discovery Survey,"Early-headlights signals for deciding whether to pursue this repo further — from data already collected by Scouting/Profile, zero new fetch. Grown by Part 2 (docs/discovery-automate-project-context-plan.md) with maturity/lifecycle-stage and repo-convention signals (security policy, build automation, deployment/Docker evidence, catalog self-description, documentation breadth).",repo-survey-definition-discovery.md,repo_dependency_support,7 discovery,RepoArchitectureDiscovery,Repo Architecture Discovery,"Candidate component partition — package manifests, deployment units, ast-grep code markers, and import/co-change coupling (docs/architecture-recovery-phase1-plan.md) — proposed at every depth it finds and stored as a hierarchy, not one chosen level (repo-analysis-funnel.md (§12) §2a), so 'which subsystem is this?' is answerable from a coarse projection without discarding the finer reading underneath. A SEPARATE survey_group from Repo Discovery Survey, deliberately: unlike every other discovery-tier analysis, both steps here DO fetch (repo_arch_detect needs a zipball, repo_arch_coupling also needs a git clone for co-change history) — bundling them into the zero-new-fetch survey above would make its own description false. Comfortably sufficient for subsystem identification; marginal for component-scoped metrics; not yet a publishable blueprint.",repo-survey-definition-architecture-discovery.md,repo_arch_detect,1 discovery,RepoArchitectureDiscovery,Repo Architecture Discovery,"Candidate component partition — package manifests, deployment units, ast-grep code markers, and import/co-change coupling (docs/architecture-recovery-phase1-plan.md) — proposed at every depth it finds and stored as a hierarchy, not one chosen level (repo-analysis-funnel.md (§12) §2a), so 'which subsystem is this?' is answerable from a coarse projection without discarding the finer reading underneath. A SEPARATE survey_group from Repo Discovery Survey, deliberately: unlike every other discovery-tier analysis, both steps here DO fetch (repo_arch_detect needs a zipball, repo_arch_coupling also needs a git clone for co-change history) — bundling them into the zero-new-fetch survey above would make its own description false. Comfortably sufficient for subsystem identification; marginal for component-scoped metrics; not yet a publishable blueprint.",repo-survey-definition-architecture-discovery.md,repo_arch_coupling,2 discovery,RepoArchitectureDiscovery,Repo Architecture Discovery,"Candidate component partition — package manifests, deployment units, ast-grep code markers, and import/co-change coupling (docs/architecture-recovery-phase1-plan.md) — proposed at every depth it finds and stored as a hierarchy, not one chosen level (repo-analysis-funnel.md (§12) §2a), so 'which subsystem is this?' is answerable from a coarse projection without discarding the finer reading underneath. A SEPARATE survey_group from Repo Discovery Survey, deliberately: unlike every other discovery-tier analysis, both steps here DO fetch (repo_arch_detect needs a zipball, repo_arch_coupling also needs a git clone for co-change history) — bundling them into the zero-new-fetch survey above would make its own description false. Comfortably sufficient for subsystem identification; marginal for component-scoped metrics; not yet a publishable blueprint.",repo-survey-definition-architecture-discovery.md,repo_arch_lens,3 diff --git a/packages/resource-explorer/docs/dr-egeria/survey-definitions/.generated.json b/packages/resource-explorer/docs/dr-egeria/survey-definitions/.generated.json index 467f809b2..b764c22db 100644 --- a/packages/resource-explorer/docs/dr-egeria/survey-definitions/.generated.json +++ b/packages/resource-explorer/docs/dr-egeria/survey-definitions/.generated.json @@ -4,8 +4,8 @@ "repo-survey-definition-assessment.md": "5ed084dccf3c7022a249895184ceae0e0c7a2ec4d8364b3154250a5de772b255", "repo-survey-definition-coarse-profile.md": "a3c057a73ea4d7b8bc01ea608ad8e8f39bf214f93f8fbdf6fafc74ba7a0e6b21", "repo-survey-definition-compliance.md": "341da49a9e568aa711029feb907424aedf490b80d5b380cbc1e0565eb1c583b9", - "repo-survey-definition-discovery.md": "7942b0f20e88b5aea6f2f0fc20d3d7d5f6ee729f7f1d4a0c34abf1a6c06c39f9", - "repo-survey-definition-full.md": "bb78f63fa5101a1a5bdcdfbb84a9cb4d72926024a65fa238050487a61cd17332", + "repo-survey-definition-discovery.md": "fcedb72866bac0cee048e2df14e33a052d0f10928e2a621faa4bfd0ef3a4cf92", + "repo-survey-definition-full.md": "e7adab91db9dd40ddabb8cd451a801b6ec3ccc59cb43cfe28a6c859e2566bfc9", "repo-survey-definition-refresh.md": "cfc60a7bfde0ec11d096e3c990c95365dc0669f7e9b2b1cb3f2ce8dcc6f95c12", "repo-survey-definition-scouting-all.md": "f34fce3eac0dec769b0257ecddc7ffffac155db4f9bf1f8c77ca35e0a6d1933b", "repo-survey-definition-scouting.md": "fe8b91a9126b3f481b65daf4e6b1e34b243ffbc483b0ecebceea28992d990052" diff --git a/packages/resource-explorer/docs/dr-egeria/survey-definitions/repo-survey-definition-discovery.md b/packages/resource-explorer/docs/dr-egeria/survey-definitions/repo-survey-definition-discovery.md index 38b5bebe1..1f70150c1 100644 --- a/packages/resource-explorer/docs/dr-egeria/survey-definitions/repo-survey-definition-discovery.md +++ b/packages/resource-explorer/docs/dr-egeria/survey-definitions/repo-survey-definition-discovery.md @@ -112,6 +112,25 @@ What can be talked to, and whether the contract is written down — from the fil ___ +## Create Governance Action Process Step +### Display Name +Repo Discovery Survey — Repo Dependency Support + +### Qualified Name +GovActionProcessStep::RepoDiscoverySurvey::repo_dependency_support + +### Description +Which curated technologies the dependency list indicates (psycopg2 -> PostgreSQL, kafka-python -> Apache Kafka), and whether Egeria already holds a technology type for each. A starting point for people, not an answer: a match says the repo indicates X, not that X is supported, and an unmatched dependency has simply not been classified yet. + +### Additional Properties +| Parameter Name | Parameter Value | +|---|---| +| executes_at | resource-explorer | +| supported_technology_type | Git Repository | +| re_analysis_step | repo_dependency_support | + +___ + ## Create Governance Action Process ### Display Name Repo Discovery Survey @@ -199,6 +218,18 @@ Any ___ +## Link Next Process Step +### Governance Action Process Step +GovActionProcessStep::RepoDiscoverySurvey::repo_interface_surface + +### Next Governance Action Process Step +GovActionProcessStep::RepoDiscoverySurvey::repo_dependency_support + +### Guard +Any + +___ + ## Link Element To Scope ### Target Element Repo Discovery Survey @@ -323,3 +354,12 @@ Repo Discovery Survey ### Scope Reference What are the public interfaces? +___ + +## Link Element To Scope +### Target Element +Repo Discovery Survey + +### Scope Reference +Do we already support these dependencies? + diff --git a/packages/resource-explorer/docs/dr-egeria/survey-definitions/repo-survey-definition-full.md b/packages/resource-explorer/docs/dr-egeria/survey-definitions/repo-survey-definition-full.md index 9713ed75a..161b5d5a4 100644 --- a/packages/resource-explorer/docs/dr-egeria/survey-definitions/repo-survey-definition-full.md +++ b/packages/resource-explorer/docs/dr-egeria/survey-definitions/repo-survey-definition-full.md @@ -530,6 +530,25 @@ Project age/lifecycle stage (nascent/emerging/established/mature), from repo_cre ___ +## Create Governance Action Process Step +### Display Name +Full Survey (all steps) — Repo Dependency Support + +### Qualified Name +GovActionProcessStep::RepoFullSurvey::repo_dependency_support + +### Description +Which curated technologies the dependency list indicates (psycopg2 -> PostgreSQL, kafka-python -> Apache Kafka), and whether Egeria already holds a technology type for each. A starting point for people, not an answer: a match says the repo indicates X, not that X is supported, and an unmatched dependency has simply not been classified yet. + +### Additional Properties +| Parameter Name | Parameter Value | +|---|---| +| executes_at | resource-explorer | +| supported_technology_type | Git Repository | +| re_analysis_step | repo_dependency_support | + +___ + ## Create Governance Action Process Step ### Display Name Full Survey (all steps) — Repo Conventions @@ -1113,6 +1132,18 @@ ___ ### Governance Action Process Step GovActionProcessStep::RepoFullSurvey::repo_maturity +### Next Governance Action Process Step +GovActionProcessStep::RepoFullSurvey::repo_dependency_support + +### Guard +Any + +___ + +## Link Next Process Step +### Governance Action Process Step +GovActionProcessStep::RepoFullSurvey::repo_dependency_support + ### Next Governance Action Process Step GovActionProcessStep::RepoFullSurvey::repo_conventions @@ -1491,6 +1522,15 @@ ___ ### Target Element Full Survey (all steps) +### Scope Reference +Do we already support these dependencies? + +___ + +## Link Element To Scope +### Target Element +Full Survey (all steps) + ### Scope Reference What deployment styles does this support? diff --git a/packages/resource-explorer/resource_explorer/configdata/analysis_catalog.yaml b/packages/resource-explorer/resource_explorer/configdata/analysis_catalog.yaml index eac3dfdbf..1942e5355 100644 --- a/packages/resource-explorer/resource_explorer/configdata/analysis_catalog.yaml +++ b/packages/resource-explorer/resource_explorer/configdata/analysis_catalog.yaml @@ -450,6 +450,23 @@ repo_analyses: candidate: false native_process_ref: null + - id: dependency_support + name: "Dependency Support" + description: "Which curated technologies the dependency list indicates (psycopg2 -> PostgreSQL, kafka-python -> Apache Kafka) and whether Egeria already holds a technology type for each. A STARTING POINT for people, not an answer: a match says the repo indicates X, not that X is supported, and an unmatched dependency has simply not been classified yet. Mapping in configdata/dependency_support.yaml, shaped to seed an Egeria Supported Technologies collection (project owner, 2026-09-12: 'A now, shaped to seed B')." + resource_types: ["repo"] + intent: discovery # reasons over project_dependencies (repo_manifest_parse's output) — zero repo fetch; the Egeria call is catalog consultation, not acquisition + perspectives: ["Architecture", "Security", "Admin", "all"] + annotation_types: ["ClassificationAnnotation"] + source: local + run_time: fast + availability: inline + action: survey + target_shape: whole_resource_only + recommended: false + egeria_registration: + candidate: false + native_process_ref: null + - id: repo_conventions name: "Repo Conventions" description: "Discovery-tier repo conventions: security policy content, build automation, deployment/Docker evidence, catalog self-description (Backstage-style catalog-info.yaml), documentation breadth." diff --git a/packages/resource-explorer/resource_explorer/configdata/dependency_support.yaml b/packages/resource-explorer/resource_explorer/configdata/dependency_support.yaml new file mode 100644 index 000000000..59ee79c91 --- /dev/null +++ b/packages/resource-explorer/resource_explorer/configdata/dependency_support.yaml @@ -0,0 +1,233 @@ +# Dependency → technology mapping: the human knowledge that lets RE say +# "this repo depends on PostgreSQL" when what it actually sees is `psycopg2`. +# +# WHY THIS FILE EXISTS — measured 2026-09-12 before anything was built. Against +# the 2,894 distinct dependency names in the registry, Egeria's technology-type +# catalog (213 entries) matched TWO by exact name. Token overlap matched 373 and +# nearly all were wrong: `apache_atlas` → "Apache Airflow" via the word +# "apache", `@babel/plugin-proposal-function-bind` → "Unity Catalog Function". +# Dependency names and technology names live in different vocabularies, and no +# string method bridges them — `psycopg2` ↔ PostgreSQL is knowledge, not +# similarity. This file IS that knowledge, curated by people, and every match +# it produces is high-precision because a person wrote the pattern. +# +# Decision (project owner, 2026-09-12): "A now, shaped to seed B" — keep the +# mapping here in RE for now, but shape each entry so it can later be promoted +# into an Egeria "Supported Technologies" collection without redesign. That is +# what `egeria_technology_type` is for: it names the deployedImplementationType +# valid value that already exists in Egeria (displayName, verified live), and +# an entry with no such type yet is one Egeria will need created — Egeria +# already types `pyegeria` as a SoftwareLibrary, which is the natural open +# metadata type for a library dependency. +# +# What a match MEANS, and what it does not. A match says "this repository +# depends on a library that indicates technology X". It does not say the +# organisation supports X — that is the human corroboration the project owner +# named ("still need corroboration and augmentation by people"). And an +# UNMATCHED dependency means "no curated technology corresponds to this name", +# never "unsupported": most of the 2,894 are unmatched simply because nobody +# has classified them yet, and the ranked unmatched list is the queue for +# doing so. +# +# Pattern syntax: matched against the dependency name lower-cased. A pattern +# ending in `*` is a prefix; otherwise it is an exact match. Maven coordinates +# match on the artifact after the colon as well as the full `group:artifact`. +# `ecosystems` is optional and restricts a technology's patterns to those +# ecosystems when a short name would otherwise collide (e.g. `pg`). + +technologies: + + - name: PostgreSQL + egeria_technology_type: "PostgreSQL Server" + patterns: + - psycopg2 + - psycopg2-binary + - psycopg + - psycopg-binary + - asyncpg + - pg8000 + - postgresql + - org.postgresql:postgresql + - pgvector + - node-postgres + - github.com/lib/pq + - github.com/jackc/pgx* + # `pg` alone is deliberately NOT a pattern: two letters collide with too + # much. Add it under `ecosystems: [javascript]` if node-postgres users need + # it, which is the collision-scoping the field exists for. + + - name: Apache Kafka + egeria_technology_type: "Apache Kafka Topic" + patterns: + - kafka-python + - confluent-kafka + - aiokafka + - kafkajs + - org.apache.kafka:* + - github.com/segmentio/kafka-go + - github.com/confluentinc/confluent-kafka-go* + - faust + - faust-streaming + + - name: Apache Airflow + egeria_technology_type: "Apache Airflow Server" + patterns: + - apache-airflow + - apache-airflow-* + - airflow + + - name: Apache Superset + egeria_technology_type: "Apache Superset" + patterns: + - apache-superset + - superset + + - name: Docker + egeria_technology_type: "Docker Container" + patterns: + - docker + - docker-py + - dockerode + - github.com/docker/docker* + - com.github.docker-java:* + - testcontainers + - testcontainers-python + - org.testcontainers:* + + - name: Kubernetes + egeria_technology_type: "Kubernetes Cluster" + patterns: + - kubernetes + - kubernetes-client + - "@kubernetes/client-node" + - io.kubernetes:* + - io.fabric8:kubernetes-client + - k8s.io/* + - sigs.k8s.io/* + + - name: DuckDB + egeria_technology_type: "DuckDB Relational Database" + patterns: + - duckdb + - duckdb-engine + - org.duckdb:duckdb_jdbc + + - name: Microsoft SQL Server + egeria_technology_type: "Microsoft SQL Server" + patterns: + - pymssql + - pyodbc + - mssql + - tedious + - com.microsoft.sqlserver:mssql-jdbc + - github.com/denisenkom/go-mssqldb + - github.com/microsoft/go-mssqldb + + - name: Oracle Database + egeria_technology_type: "Oracle Database Server" + patterns: + - cx_oracle + - oracledb + - com.oracle.database.jdbc:* + - com.oracle.ojdbc:* + - github.com/godror/godror + + - name: Marquez + egeria_technology_type: "Marquez Server" + patterns: + - marquez-python + - marquez-airflow + - io.github.marquezproject:* + + - name: OpenLineage + egeria_technology_type: null # no Egeria type yet — promotion candidate + patterns: + - openlineage-python + - openlineage-airflow + - openlineage-dbt + - openlineage-sql + - io.openlineage:* + - apache-airflow-providers-openlineage + + - name: Unity Catalog + egeria_technology_type: "Unity Catalog Server" + patterns: + - unitycatalog + - unitycatalog-client + - io.unitycatalog:* + + - name: Egeria (pyegeria) + egeria_technology_type: "pyegeria" + patterns: + - pyegeria + - org.odpi.egeria:* + + - name: Jupyter + egeria_technology_type: "Jupyter Notebook File" + patterns: + - jupyter + - jupyterlab + - notebook + - ipykernel + - nbformat + - nbconvert + - papermill + + - name: MLflow + egeria_technology_type: null # no Egeria type yet — promotion candidate + patterns: + - mlflow + - mlflow-skinny + - org.mlflow:* + + - name: Apache Spark + egeria_technology_type: null # no Egeria type yet — promotion candidate + patterns: + - pyspark + - org.apache.spark:* + - delta-spark + - io.delta:* + + - name: Redis + egeria_technology_type: null # no Egeria type yet — promotion candidate + patterns: + - redis + - redis-py + - aioredis + - ioredis + - redis.clients:jedis + - io.lettuce:lettuce-core + - github.com/redis/go-redis* + - github.com/go-redis/redis* + + - name: Elasticsearch / OpenSearch + egeria_technology_type: null # no Egeria type yet — promotion candidate + patterns: + - elasticsearch + - elasticsearch-dsl + - opensearch-py + - "@elastic/elasticsearch" + - "@opensearch-project/opensearch" + - org.elasticsearch:* + - org.opensearch:* + - github.com/elastic/go-elasticsearch* + + - name: MongoDB + egeria_technology_type: null # no Egeria type yet — promotion candidate + patterns: + - pymongo + - motor + - mongoose + - mongodb + - org.mongodb:* + - go.mongodb.org/mongo-driver* + + - name: RabbitMQ / AMQP + egeria_technology_type: null # no Egeria type yet — promotion candidate + patterns: + - pika + - aio-pika + - amqplib + - com.rabbitmq:amqp-client + - github.com/rabbitmq/amqp091-go + - github.com/streadway/amqp diff --git a/packages/resource-explorer/resource_explorer/configdata/question_catalog.yaml b/packages/resource-explorer/resource_explorer/configdata/question_catalog.yaml index b7bb969b7..41e9c722a 100644 --- a/packages/resource-explorer/resource_explorer/configdata/question_catalog.yaml +++ b/packages/resource-explorer/resource_explorer/configdata/question_catalog.yaml @@ -310,10 +310,17 @@ repo_questions: - Select - Deploy answering: - kind: human - analysis_ids: [] + kind: mixed + analysis_ids: + - dependency_support checks: [] - note: N/A — human-supplied via Enrichment Context form - may also be a new Agent over RAG content. + note: Machine-informed, human-confirmed. dependency_support cross-references the dependency + list against a curated technology mapping (configdata/dependency_support.yaml) linked to + Egeria's technology types, and reports what each dependency INDICATES plus a ranked list of + names nobody has classified yet. Whether a technology is supported remains a human judgement + — the analysis is the starting point the project owner asked for (2026-09-11), not the answer. + Was `human` until 2026-09-12; the earlier note read — + N/A — human-supplied via Enrichment Context form - may also be a new Agent over RAG content. Cross-reference against Egeria's catalog of already-supported dependencies, validated/completed by human input — likely needs a per-dependency answer, not one blanket answer for the whole question. answering_mechanism: Egeria Queries + Human-Supplied diff --git a/packages/resource-explorer/resource_explorer/surveyors/dependency_support.py b/packages/resource-explorer/resource_explorer/surveyors/dependency_support.py new file mode 100644 index 000000000..b3bd20244 --- /dev/null +++ b/packages/resource-explorer/resource_explorer/surveyors/dependency_support.py @@ -0,0 +1,274 @@ +"""Which technologies does a repository's dependency list indicate — and does +Egeria already know about them? + +Answers the question catalog's *"Do we already support these dependencies?"* +as a **starting point for people**, not as an answer. Decision (project owner, +2026-09-11): Egeria may hold a starting point that "would still need +corroboration and augmentation by people"; and (2026-09-12) "A now, shaped to +seed B" — the curated mapping lives in RE (`configdata/dependency_support.yaml`) +today, with each entry linked to the Egeria technology type it will later be +promoted into. + +**Why a curated mapping and not string matching** — measured 2026-09-12 before +building: against 2,894 distinct dependency names, Egeria's 213 technology +types matched TWO by exact name, and token overlap matched 373 that were nearly +all wrong (`apache_atlas` → Apache Airflow via "apache"). `psycopg2` ↔ +PostgreSQL is knowledge, not similarity; the YAML is that knowledge. See the +file's own header. + +**Three things a reader must be able to tell apart**, because collapsing them +is this codebase's dominant bug class: + +* a dependency that MATCHED a curated technology — "this repo indicates X"; +* a dependency that matched NOTHING — "no curated technology corresponds to + this name", which is almost always "nobody has classified it yet" and is + never "unsupported"; +* a technology whose Egeria type could NOT BE CHECKED — Egeria unreachable — + which is not the same as the type being absent. + +The matcher here is pure and needs no Egeria; the Egeria check is a separate +function so the two failure modes cannot be confused for one another. +""" +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from functools import lru_cache +from pathlib import Path + +log = logging.getLogger(__name__) + +_DEFAULT_MAPPING_PATH = Path(__file__).resolve().parent.parent / "configdata" / "dependency_support.yaml" + + +class DependencySupportMappingError(ValueError): + """The mapping file is malformed. Raised loudly: a mapping that half-loads + would silently turn every dependency into 'unmatched'.""" + + +@dataclass(frozen=True) +class Technology: + name: str + #: Egeria technology-type displayName this promotes into, or None when no + #: such type exists yet (a promotion candidate for the B step). + egeria_technology_type: str | None + patterns: tuple[str, ...] + ecosystems: frozenset[str] = frozenset() # empty = all + + def matches(self, dep_name: str, ecosystem: str = "") -> bool: + if self.ecosystems and ecosystem and ecosystem.lower() not in self.ecosystems: + return False + name = (dep_name or "").strip().lower() + if not name: + return False + # Maven `group:artifact` also matches on the bare artifact, so a pattern + # written as `postgresql` catches `org.postgresql:postgresql`. + candidates = {name} + if ":" in name: + candidates.add(name.rsplit(":", 1)[-1]) + for pat in self.patterns: + p = pat.lower() + if p.endswith("*"): + if any(c.startswith(p[:-1]) for c in candidates): + return True + elif p in candidates: + return True + return False + + +@dataclass +class TechnologyMatch: + technology: Technology + #: The dependency names (from this repo) that indicated it. + dependencies: list[str] = field(default_factory=list) + #: "present" / "absent" / "unchecked" — whether Egeria has the linked type. + #: `unchecked` when Egeria could not be reached; `absent` when it was + #: reached and the type is not there; and for a technology with no + #: `egeria_technology_type` at all, "no-type" — a promotion candidate. + egeria_state: str = "unchecked" + + +@dataclass +class SupportAssessment: + """One repository's result.""" + + matches: list[TechnologyMatch] + unmatched: list[str] + total_dependencies: int + #: "checked" / "unreachable" / "skipped" — did we ask Egeria at all, and + #: could it answer? Carried at the top so a reader never has to infer it + #: from the per-match states. + egeria_check: str + egeria_check_detail: str = "" + + @property + def matched_dependency_count(self) -> int: + return sum(len(m.dependencies) for m in self.matches) + + def as_findings(self) -> list[dict]: + """Rows for `project_analysis_findings`, kind `dependency_support`. + + One row per matched technology, plus ONE coverage row. Unmatched names + are NOT persisted one-per-row — a repo with 800 dependencies would + write 800 rows saying "unclassified" — they are re-derived at read time + from `project_dependencies` minus the mapping, which is also what lets + the cross-repo ranked unmatched list exist without a table of its own. + """ + rows = [] + for m in self.matches: + rows.append({ + "check_name": "technology", + "label": m.technology.name, + "summary": (f"{m.technology.name} — indicated by " + f"{', '.join(sorted(m.dependencies)[:6])}" + + (f" (+{len(m.dependencies)-6} more)" if len(m.dependencies) > 6 else "")), + "confidence": 85 if m.egeria_state == "present" else 70, + "detail": { + "dependencies": sorted(m.dependencies), + "egeria_technology_type": m.technology.egeria_technology_type, + "egeria_state": m.egeria_state, + }, + }) + rows.append({ + "check_name": "coverage", + "label": self.egeria_check, + "summary": (f"{self.matched_dependency_count} of {self.total_dependencies} dependencies " + f"indicate {len(self.matches)} curated technolog{'y' if len(self.matches)==1 else 'ies'}; " + f"{len(self.unmatched)} have no curated technology yet"), + "confidence": 100 if self.egeria_check == "checked" else 0, + "detail": { + "total_dependencies": self.total_dependencies, + "matched_dependencies": self.matched_dependency_count, + "unmatched_dependencies": len(self.unmatched), + "technologies": len(self.matches), + "egeria_check": self.egeria_check, + "egeria_check_detail": self.egeria_check_detail, + }, + }) + return rows + + +@lru_cache(maxsize=4) +def load_mapping(path: Path | str | None = None) -> tuple[Technology, ...]: + """The curated technologies, validated. Cached per path.""" + import yaml + + p = Path(path) if path else _DEFAULT_MAPPING_PATH + try: + raw = yaml.safe_load(p.read_text(encoding="utf-8")) or {} + except FileNotFoundError as exc: + raise DependencySupportMappingError(f"mapping file not found: {p}") from exc + techs = raw.get("technologies") + if not isinstance(techs, list) or not techs: + raise DependencySupportMappingError(f"{p}: 'technologies' must be a non-empty list") + + out: list[Technology] = [] + seen: set[str] = set() + allowed = {"name", "egeria_technology_type", "patterns", "ecosystems"} + for i, t in enumerate(techs): + if not isinstance(t, dict): + raise DependencySupportMappingError(f"{p}: technologies[{i}] is not a mapping") + unknown = set(t) - allowed + if unknown: + raise DependencySupportMappingError( + f"{p}: technologies[{i}] has unknown keys {sorted(unknown)} — " + f"a typo here silently drops patterns") + name = str(t.get("name") or "").strip() + if not name: + raise DependencySupportMappingError(f"{p}: technologies[{i}] has no name") + if name.lower() in seen: + raise DependencySupportMappingError(f"{p}: technology {name!r} listed twice") + seen.add(name.lower()) + pats = t.get("patterns") + if not isinstance(pats, list) or not pats or not all(isinstance(x, str) and x.strip() for x in pats): + raise DependencySupportMappingError(f"{p}: {name!r} needs a non-empty list of string patterns") + for x in pats: + if len(x.strip().rstrip("*")) < 3: + raise DependencySupportMappingError( + f"{p}: {name!r} pattern {x!r} is shorter than 3 characters — " + f"that is how `apache` matched Airflow for 105 unrelated packages") + eco = t.get("ecosystems") or [] + et = t.get("egeria_technology_type") + out.append(Technology( + name=name, + egeria_technology_type=str(et).strip() if et else None, + patterns=tuple(x.strip() for x in pats), + ecosystems=frozenset(str(e).lower() for e in eco), + )) + return tuple(out) + + +def clear_cache() -> None: + load_mapping.cache_clear() + + +def assess(dependencies: list[dict], *, technologies: tuple[Technology, ...] | None = None, + egeria_types: dict[str, bool] | None = None, + egeria_check: str = "skipped", egeria_check_detail: str = "") -> SupportAssessment: + """Pure: match a repo's dependency rows against the mapping. + + `dependencies` are `project_dependencies` rows (need `dep_name`, optionally + `ecosystem`). `egeria_types` is {displayName: True} for the types Egeria + was found to hold — pass None when Egeria was not consulted, and set + `egeria_check` accordingly so the assessment says which it was. + """ + techs = technologies or load_mapping() + by_tech: dict[str, TechnologyMatch] = {} + unmatched: list[str] = [] + names_seen: set[str] = set() + + for row in dependencies: + dep = str(row.get("dep_name") or "").strip() + eco = str(row.get("ecosystem") or "") + if not dep or dep.lower() in names_seen: + continue + names_seen.add(dep.lower()) + hit = False + for t in techs: + if t.matches(dep, eco): + hit = True + m = by_tech.setdefault(t.name, TechnologyMatch(t)) + m.dependencies.append(dep) + if not hit: + unmatched.append(dep) + + for m in by_tech.values(): + t = m.technology + if not t.egeria_technology_type: + m.egeria_state = "no-type" + elif egeria_types is None: + m.egeria_state = "unchecked" + else: + m.egeria_state = "present" if egeria_types.get(t.egeria_technology_type) else "absent" + + return SupportAssessment( + matches=sorted(by_tech.values(), key=lambda m: (-len(m.dependencies), m.technology.name)), + unmatched=sorted(unmatched, key=str.lower), + total_dependencies=len(names_seen), + egeria_check=egeria_check, + egeria_check_detail=egeria_check_detail, + ) + + +def egeria_technology_types_present(names: list[str]) -> tuple[dict[str, bool] | None, str, str]: + """({displayName: present}, check_state, detail) — asks Egeria once. + + Returns (None, "unreachable", why) when Egeria cannot be reached, so the + caller records "could not check" rather than "absent". Never raises: a + survey step must not fail because the catalog was down, and the three-state + result IS the error handling. + """ + if not names: + return {}, "checked", "no linked types to verify" + try: + from resource_explorer.config import get_config + from resource_explorer.surveyors.egeria_tech_type_catalog import EgeriaTechTypeCatalog + + e = get_config().egeria + cat = EgeriaTechTypeCatalog(e.platform_url, e.view_server, e.user_id, e.user_password) + cat.connect() + have = {t.get("displayName") for t in cat.list_technology_types()} + except Exception as exc: # noqa: BLE001 - the whole point is to report, not raise + log.warning("dependency_support: Egeria technology-type catalog unreachable: %s", exc) + return None, "unreachable", f"{type(exc).__name__}: {exc}" + return {n: (n in have) for n in names}, "checked", f"{len(have)} technology types in catalog" diff --git a/packages/resource-explorer/resource_explorer/surveyors/repo_survey_definition_adapter.py b/packages/resource-explorer/resource_explorer/surveyors/repo_survey_definition_adapter.py index 597771058..b2a4a0aa6 100644 --- a/packages/resource-explorer/resource_explorer/surveyors/repo_survey_definition_adapter.py +++ b/packages/resource-explorer/resource_explorer/surveyors/repo_survey_definition_adapter.py @@ -36,6 +36,7 @@ from __future__ import annotations import json +import logging from contextlib import contextmanager from dataclasses import dataclass, field from typing import Callable @@ -80,6 +81,7 @@ LanguageSurveyor, LicenseClassifierSurveyor, RepoClassificationSurveyor, + DependencySupportSurveyor, MaturitySurveyor, RagIngestionSurveyor, RepoConventionsSurveyor, @@ -96,6 +98,11 @@ register_adapter, ) +# Three call sites below (the STEP-introspection warnings) used `log` without +# ever defining it — latent NameErrors on their warning paths since they were +# written. Defined here 2026-09-12 when a fourth use was added. +log = logging.getLogger(__name__) + # qualified_name of the "Repo Coarse Scout" Survey Definition (a # GovernanceActionProcess authored in Egeria via Dr.Egeria, chaining just @@ -764,6 +771,18 @@ def _resource_providers_for(project, registry) -> dict[str, ResourceProvider]: ["ClassificationAnnotation"], # Reads project_stats.repo_created_at — no fetch of its own. ), + "repo_dependency_support": StepInfo( + "repo_dependency_support", DependencySupportSurveyor, + "Which curated technologies the dependency list indicates (psycopg2 -> " + "PostgreSQL, kafka-python -> Apache Kafka), and whether Egeria already " + "holds a technology type for each. A starting point for people, not an " + "answer: a match says the repo indicates X, not that X is supported, and " + "an unmatched dependency has simply not been classified yet.", + ["ClassificationAnnotation"], + # Reads project_dependencies (repo_manifest_parse's output) and the + # curated mapping; asks Egeria's technology-type catalog once. No fetch + # of the repository itself — Discovery tier under rule 17. + ), "repo_conventions": StepInfo( "repo_conventions", RepoConventionsSurveyor, "Discovery-tier repo conventions: security policy content, build " @@ -1845,6 +1864,101 @@ def _ci_quality_trend(registry, slug: str) -> list[dict]: ] +def _parse_detail_json(row: dict) -> dict: + """The findings table stores `detail_json` as text; a row from + query_findings() has no parsed `detail`. Tolerates both shapes so a reader + works whether it is handed a raw row or one something already parsed.""" + d = row.get("detail") + if isinstance(d, dict): + return d + raw = row.get("detail_json") + if not raw: + return {} + try: + parsed = json.loads(raw) if isinstance(raw, str) else raw + except (TypeError, ValueError): + return {} + return parsed if isinstance(parsed, dict) else {} + + +def _dependency_support_results(registry, slug: str) -> dict: + """Matched technologies + coverage from the persisted findings, and the + UNMATCHED list re-derived live from project_dependencies minus the mapping. + + Unmatched is derived rather than stored on purpose (see the surveyor): the + mapping is curated and grows, so a name unmatched at survey time may match + today, and a stored list would report stale gaps. Deriving it also means the + same function serves the cross-repo "nobody has classified this yet" queue. + """ + from resource_explorer.surveyors import dependency_support as ds + + rows = registry.query_findings(slug, "dependency_support") + if not rows: + return {"_status": {"state": result_status.NEVER_RUN, + "hint": "No dependency-support assessment yet — run the analysis."}} + + technologies, coverage = [], {} + for r in rows: + # query_findings hands back `detail_json` as a JSON string, not a + # parsed `detail` dict — the first version of this reader read the + # wrong key and every technology came back `unchecked` with no + # dependencies. Caught by running it against a real repo, not by tests. + detail = _parse_detail_json(r) + if r["check_name"] == "technology": + technologies.append({ + "technology": r["label"], "summary": r["summary"], + "dependencies": detail.get("dependencies") or [], + "egeria_technology_type": detail.get("egeria_technology_type"), + "egeria_state": detail.get("egeria_state") or "unchecked", + "confidence": r.get("confidence"), + }) + elif r["check_name"] == "coverage": + coverage = {**detail, "summary": r["summary"], "label": r["label"]} + + # Re-derive unmatched against TODAY's mapping. + try: + deps = registry.query_dependencies(slug) or [] + live = ds.assess(deps, egeria_types=None, egeria_check="not-consulted") + unmatched = live.unmatched + except Exception: # a derivation failure must not hide the persisted rows + log.debug("dependency_support: could not re-derive unmatched for %s", slug, exc_info=True) + unmatched = None + + return { + "technologies": technologies, + "coverage": coverage, + # None, not [], when it could not be derived — a reader must be able to + # tell "nothing unmatched" from "could not compute". + "unmatched": unmatched, + "unmatched_count": len(unmatched) if unmatched is not None else None, + "surveyed_at": rows[0].get("surveyed_at", "") if rows else "", + # What the reader is looking at. A human corroborates; this does not + # claim support. + "message": ("A starting point: each technology below is INDICATED by a " + "dependency name via a curated mapping. Whether it is supported " + "is a human judgement. Unmatched names have not been classified, " + "which is not the same as unsupported."), + } + + +def _dependency_support_headline(registry, slug: str) -> dict | None: + res = _dependency_support_results(registry, slug) + cov = res.get("coverage") or {} + if not cov and not res.get("technologies"): + return None + check = cov.get("egeria_check") or "skipped" + n_t = len(res.get("technologies") or []) + n_u = res.get("unmatched_count") + if cov.get("label") == "no-dependencies": + return {"label": "No dependency rows held — nothing to assess", "status": "warn"} + label = (f"{n_t} technolog{'y' if n_t == 1 else 'ies'} indicated" + + (f", {n_u} dependencies unclassified" if n_u is not None else "")) + if check == "unreachable": + label += " — Egeria could not be checked" + return {"label": label, "status": "warn"} + return {"label": label, "status": "info" if n_t else "warn"} + + def _maturity_results(registry, slug: str) -> dict: # Same uniform finding shape as _license_results — single # current-state classification, no trend (see maturity.py's docstring). @@ -4066,6 +4180,13 @@ class AnalysisKind: # recovery's own evidence, and giving it a separate id is what lets a # question ask for "the picture" without also pulling in the full, # possibly 100+-component list that answers a different question. + "dependency_support": AnalysisKind( + "dependency_support", ["repo_dependency_support"], + results=AnalysisKindResults( + _dependency_support_results, None, "custom", + headline_reader=_dependency_support_headline, + ), + ), "architecture_diagram": AnalysisKind( # OWNS NO STEPS, and derives from architecture_recovery's two. # diff --git a/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/__init__.py b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/__init__.py index 653497d57..461860ee0 100644 --- a/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/__init__.py +++ b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/__init__.py @@ -26,6 +26,7 @@ from resource_explorer.surveyors.sub_surveyors.repo_classification import RepoClassificationSurveyor from resource_explorer.surveyors.sub_surveyors.security_features import SecurityFeaturesSurveyor from resource_explorer.surveyors.sub_surveyors.maturity import MaturitySurveyor +from resource_explorer.surveyors.sub_surveyors.dependency_support import DependencySupportSurveyor from resource_explorer.surveyors.sub_surveyors.repo_conventions import RepoConventionsSurveyor from resource_explorer.surveyors.sub_surveyors.sub_resource_survey import ( SubResourceSurveyor, @@ -67,6 +68,7 @@ "RepoClassificationSurveyor", "SecurityFeaturesSurveyor", "MaturitySurveyor", + "DependencySupportSurveyor", "RepoConventionsSurveyor", "SubResourceSurveyor", "FileInventorySurveyor", diff --git a/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/dependency_support.py b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/dependency_support.py new file mode 100644 index 000000000..bfe5c6524 --- /dev/null +++ b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/dependency_support.py @@ -0,0 +1,116 @@ +"""Sub-surveyor: which curated technologies does this repo's dependency list +indicate, and does Egeria already hold a type for each → ClassificationAnnotation. + +Zero-fetch: reads `project_dependencies` (written by repo_manifest_parse) and +the curated mapping, and asks Egeria's technology-type catalog once. Discovery +tier under CLAUDE.md rule 17 — it reasons over what an earlier step collected +and fetches nothing from the repository. The Egeria call is catalog +consultation, not acquisition. + +A **starting point for people**, per the project owner (2026-09-11): a match +means "this repo indicates technology X", not "we support X". The per-repo +result persists the matched technologies and one coverage row; the unmatched +names are re-derived at read time so the cross-repo ranked "nobody has +classified this yet" list — the actual human corroboration queue — needs no +table of its own. See `surveyors/dependency_support.py`. +""" +from __future__ import annotations + +import logging +from datetime import UTC, datetime + +from resource_explorer.step_outcome import StepOutcome +from resource_explorer.surveyors import dependency_support as ds +from resource_explorer.surveyors.base_surveyor import BaseSurveyor +from resource_explorer.surveyors.survey_report import Annotation, ClassificationAnnotation + +log = logging.getLogger(__name__) + +STEP = "DependencySupportAssessment" +KIND = "dependency_support" + + +class DependencySupportSurveyor(BaseSurveyor): + """One ClassificationAnnotation per matched technology, plus a coverage + annotation that says how much of the dependency list the mapping covered + and whether Egeria could be consulted.""" + + @property + def step_name(self) -> str: + return STEP + + def run(self) -> list[Annotation]: + results: list[Annotation] = [] + slug = self.project.slug + try: + deps = self.registry.query_dependencies(slug) or [] + if not deps: + # Nothing to assess is a real, reportable state — not a zero + # technologies matched. Either manifest parsing has not run, or + # the repo declares no dependencies; both are `unverified` here + # because this step cannot tell which from its own inputs. + outcome = StepOutcome("unverified", cause="no project_dependencies rows", + detail={"hint": "run repo_manifest_parse first"}) + results.append(ClassificationAnnotation( + summary="No dependency rows held for this repository — nothing to assess " + "(manifest parsing may not have run)", + analysis_step=STEP, check_name="coverage", + candidate_classifications=[], confidence=0, + json_properties={"total_dependencies": 0, **outcome.as_row()}, + )) + self.registry.upsert_finding(slug, KIND, [{ + "check_name": "coverage", "label": "no-dependencies", + "summary": "No dependency rows held — nothing to assess", "confidence": 0, + "detail": {"total_dependencies": 0, "egeria_check": "skipped", + "egeria_check_detail": "no dependencies to assess"}, + }], surveyed_at=datetime.now(UTC).isoformat()) + return results + + techs = ds.load_mapping() + linked = sorted({t.egeria_technology_type for t in techs if t.egeria_technology_type}) + present, check, detail = ds.egeria_technology_types_present(linked) + assessment = ds.assess(deps, technologies=techs, egeria_types=present, + egeria_check=check, egeria_check_detail=detail) + + for m in assessment.matches: + results.append(ClassificationAnnotation( + summary=f"{m.technology.name} — indicated by {', '.join(sorted(m.dependencies)[:6])}", + analysis_step=STEP, check_name="technology", + candidate_classifications=[m.technology.name], + confidence=85 if m.egeria_state == "present" else 70, + json_properties={ + "technology": m.technology.name, + "dependencies": sorted(m.dependencies), + "egeria_technology_type": m.technology.egeria_technology_type, + "egeria_state": m.egeria_state, + **StepOutcome("recovered").as_row(), + }, + )) + + # The coverage annotation carries the Egeria check state at the + # top: `unreachable` is "could not check", and a reader must be + # able to see that without inferring it from per-match states. + cov_outcome = (StepOutcome("recovered") if check == "checked" + else StepOutcome("partial", cause=f"Egeria technology catalog {check}", + detail={"egeria_check_detail": detail})) + results.append(ClassificationAnnotation( + summary=assessment.as_findings()[-1]["summary"], + analysis_step=STEP, check_name="coverage", + candidate_classifications=[], + confidence=100 if check == "checked" else 50, + json_properties={ + "total_dependencies": assessment.total_dependencies, + "matched_dependencies": assessment.matched_dependency_count, + "unmatched_dependencies": len(assessment.unmatched), + "technologies": len(assessment.matches), + "egeria_check": check, + **cov_outcome.as_row(), + }, + )) + + self.registry.upsert_finding(slug, KIND, assessment.as_findings(), + surveyed_at=datetime.now(UTC).isoformat()) + except Exception as exc: + log.exception("DependencySupportSurveyor failed for %s", slug) + self._warn(results, str(exc)) + return results diff --git a/packages/resource-explorer/resource_explorer/web/static/index.html b/packages/resource-explorer/resource_explorer/web/static/index.html index ad7db4727..be936b631 100644 --- a/packages/resource-explorer/resource_explorer/web/static/index.html +++ b/packages/resource-explorer/resource_explorer/web/static/index.html @@ -3215,6 +3215,10 @@

    // picture is the shape that makes a card look populated while withholding // the only thing it exists to show. See _renderArchitectureDiagramResults. architecture_diagram: 'custom', + // Three states a reader must tell apart — matched technology, unmatched + // (unclassified, NOT unsupported), and Egeria-could-not-check — which no + // generic mode renders. See _renderDependencySupportResults. + dependency_support: 'custom', // Added 2026-08-23 — repo_manifest_parse's card (project_dependencies + // project_analysis_findings kind="ci_quality"/"repo_conventions", all in // one run). Same shape as code_symbol_extraction's own results. @@ -3666,6 +3670,71 @@

    `; } +// _renderDependencySupportResults — kind-plugin for `dependency_support`. +// +// The question is "do we already support these dependencies?" and the honest +// answer is a STARTING POINT, not a verdict — the project owner's words +// (2026-09-11). So the card leads with what the mapping indicated, keeps the +// Egeria check state visible at the top rather than buried per-row, and +// renders the unmatched list as "not yet classified" with a count, because +// most of them are simply names nobody has mapped — and calling them +// "unsupported" would be the confident wrong answer this codebase exists to +// avoid. +function _renderDependencySupportResults(data) { + if (!data || (!data.technologies && !data.coverage)) return _renderEmptyResultState(data); + const cov = data.coverage || {}; + const techs = data.technologies || []; + const check = cov.egeria_check || 'skipped'; + + if (cov.label === 'no-dependencies') { + return `
    No dependency rows are held for this ` + + `repository, so there is nothing to assess — run manifest_parse first.
    `; + } + + const checkBadge = check === 'checked' + ? `Egeria checked` + : check === 'unreachable' + ? `Egeria could not be checked` + : `Egeria not consulted`; + + const head = `
    ` + + `
    ${_esc(cov.summary || '')}
    ${checkBadge}
    ` + + `
    ${_esc(data.message || '')}
    `; + + const stateLabel = { present: 'in Egeria', absent: 'not in Egeria', unchecked: 'unchecked', + 'no-type': 'no Egeria type yet' }; + const rows = techs.map(t => { + const st = t.egeria_state || 'unchecked'; + const cls = st === 'present' ? 'text-emerald-300' : st === 'absent' ? 'text-amber-300' : 'text-slate-500'; + const deps = (t.dependencies || []); + return `
    ` + + `
    ${_esc(t.technology)}` + + `${_esc(stateLabel[st] || st)}` + + (t.egeria_technology_type ? `${_esc(t.egeria_technology_type)}` : '') + + `
    ` + + `
    ${_esc(deps.slice(0, 8).join(', '))}${deps.length > 8 ? ` +${deps.length - 8} more` : ''}
    ` + + `
    `; + }).join(''); + const techBlock = techs.length + ? `
    ${rows}
    ` + : `
    No dependency matched a curated technology.
    `; + + // Unmatched: null means "could not derive", which is different from "none". + let unmatchedBlock; + if (data.unmatched === null || data.unmatched === undefined) { + unmatchedBlock = `
    Unclassified list could not be derived.
    `; + } else if (!data.unmatched.length) { + unmatchedBlock = `
    Every dependency matched a curated technology.
    `; + } else { + const u = data.unmatched; + unmatchedBlock = `
    ` + + `${u.length} dependenc${u.length === 1 ? 'y' : 'ies'} not yet classified — a curation queue, not a verdict` + + `
    ` + + `${_esc(u.join(', '))}
    `; + } + return head + techBlock + unmatchedBlock; +} + function _renderManifestParseResults(data) { const rows = [ _renderManifestParseSub('Dependencies', data.dependencies), @@ -4524,6 +4593,7 @@

    manifest_parse: _renderManifestParseResults, architecture_recovery: _renderArchitectureRecoveryResults, architecture_diagram: _renderArchitectureDiagramResults, + dependency_support: _renderDependencySupportResults, dependency_analysis: _renderDependencyAnalysisResults, data_file_profiling: _renderDataFileProfilingResults, language_file_classification: _renderLanguageFileClassificationResults, diff --git a/packages/resource-explorer/tests/test_analysis_catalog_reader.py b/packages/resource-explorer/tests/test_analysis_catalog_reader.py index e78b78f06..55f53b6c7 100644 --- a/packages/resource-explorer/tests/test_analysis_catalog_reader.py +++ b/packages/resource-explorer/tests/test_analysis_catalog_reader.py @@ -165,7 +165,14 @@ def test_discovery_is_the_zero_fetch_derivation_tier(self): # 2026-08-28, when it moved to Assessment: naming the # weakest dimension is a judgement against criteria, and # that signature outranks its zero-fetch cost. - "interface_surface"} \ + "interface_surface", + # dependency_support (2026-09-12): the same shape as + # architecture_summary — its input is repo_manifest_parse's + # project_dependencies rows plus a curated mapping, and it + # fetches nothing from the repository. The one Egeria call it + # makes is catalog consultation, not acquisition, and it + # declares requires_resources={} so the loop below holds. + "dependency_support"} \ | self.DISCOVERY_FETCHES_ANYWAY for aid in ids - self.DISCOVERY_FETCHES_ANYWAY: for step in REPO_ANALYSIS_STEP_MAP.get(aid, []): diff --git a/packages/resource-explorer/tests/test_dependency_support.py b/packages/resource-explorer/tests/test_dependency_support.py new file mode 100644 index 000000000..75b6108c7 --- /dev/null +++ b/packages/resource-explorer/tests/test_dependency_support.py @@ -0,0 +1,334 @@ +"""`dependency_support` — which technologies a repo's dependencies indicate, +and whether Egeria knows them. + +Built 2026-09-12 as "A now, shaped to seed B" (project owner): a curated +mapping in RE, each entry linked to the Egeria technology type it will promote +into. The design followed a measurement, not an assumption: against 2,894 +dependency names Egeria's 213 types matched TWO exactly, and token overlap +matched 373 that were nearly all wrong. The tests here pin the things that +measurement taught — chiefly that "unmatched" must never read as "unsupported", +and that a short or greedy pattern is how `apache` matched Airflow 105 times. +""" +from __future__ import annotations + +import json +import textwrap + +import pytest + +from resource_explorer.surveyors import dependency_support as ds + + +@pytest.fixture(autouse=True) +def _fresh(): + ds.clear_cache() + yield + ds.clear_cache() + + +def _write_mapping(tmp_path, body: str): + p = tmp_path / "m.yaml" + p.write_text(textwrap.dedent(body)) + return p + + +# ── the mapping file ──────────────────────────────────────────────────────── + +class TestTheMappingIsValidatedLoudly: + """A mapping that half-loads silently turns every dependency into + 'unmatched' — so every malformation raises.""" + + def test_the_real_mapping_loads(self): + techs = ds.load_mapping() + assert len(techs) >= 10 + assert any(t.name == "PostgreSQL" for t in techs) + + def test_unknown_keys_are_rejected(self, tmp_path): + p = _write_mapping(tmp_path, """ + technologies: + - name: X + pattern: [foo] # typo for `patterns` — would silently drop everything + """) + with pytest.raises(ds.DependencySupportMappingError, match="unknown keys"): + ds.load_mapping(p) + + def test_a_pattern_shorter_than_three_characters_is_rejected(self, tmp_path): + """`pg` and `io` match half the ecosystem. This is the guard against the + greedy-token failure, expressed as a floor.""" + p = _write_mapping(tmp_path, """ + technologies: + - name: X + patterns: [pg] + """) + with pytest.raises(ds.DependencySupportMappingError, match="shorter than 3"): + ds.load_mapping(p) + + def test_a_prefix_pattern_is_measured_without_its_star(self, tmp_path): + p = _write_mapping(tmp_path, """ + technologies: + - name: X + patterns: ["ab*"] + """) + with pytest.raises(ds.DependencySupportMappingError, match="shorter than 3"): + ds.load_mapping(p) + + def test_duplicate_technology_names_are_rejected(self, tmp_path): + p = _write_mapping(tmp_path, """ + technologies: + - name: Redis + patterns: [redis] + - name: redis + patterns: [ioredis] + """) + with pytest.raises(ds.DependencySupportMappingError, match="listed twice"): + ds.load_mapping(p) + + def test_every_real_pattern_meets_the_floor(self): + for t in ds.load_mapping(): + for pat in t.patterns: + assert len(pat.rstrip("*")) >= 3, (t.name, pat) + + +# ── matching ──────────────────────────────────────────────────────────────── + +class TestMatching: + def _t(self, *patterns, ecosystems=()): + return ds.Technology("T", None, tuple(patterns), frozenset(ecosystems)) + + def test_exact_is_case_insensitive(self): + assert self._t("psycopg2").matches("Psycopg2") + + def test_prefix(self): + t = self._t("apache-airflow-*") + assert t.matches("apache-airflow-providers-postgres") + assert not t.matches("apache-airflow") # prefix needs the dash after + + def test_maven_coordinate_matches_on_artifact_and_on_full(self): + t = self._t("postgresql") + assert t.matches("org.postgresql:postgresql") + t2 = self._t("org.apache.kafka:*") + assert t2.matches("org.apache.kafka:kafka-clients") + + def test_ecosystem_scoping(self): + t = self._t("pgx", ecosystems=["go"]) + assert t.matches("pgx", "go") + assert not t.matches("pgx", "python"), "ecosystem scope was ignored" + assert t.matches("pgx", ""), "an unknown ecosystem must not exclude — it is 'not stated', not 'wrong'" + + def test_empty_name_never_matches(self): + assert not self._t("redis").matches("") + + def test_THE_false_positives_that_motivated_this_design_do_not_match(self): + """The token-overlap probe (2026-09-12) produced these. The curated + mapping must not.""" + techs = ds.load_mapping() + for dep in ("apache_atlas", "apache-flink", "apache-tvm-ffi", + "@babel/plugin-proposal-function-bind", "antlr4-python3-runtime", + "@babel/plugin-transform-runtime"): + hits = [t.name for t in techs if t.matches(dep)] + assert not hits, f"{dep!r} matched {hits} — that is the greedy match this design exists to prevent" + + def test_the_seed_matches_the_known_real_cases(self): + techs = {t.name: t for t in ds.load_mapping()} + assert techs["PostgreSQL"].matches("psycopg2-binary") + assert techs["Apache Kafka"].matches("confluent-kafka") + assert techs["Apache Airflow"].matches("apache-airflow-providers-postgres") + assert techs["Egeria (pyegeria)"].matches("pyegeria") + + +# ── the three states ──────────────────────────────────────────────────────── + +class TestAZeroSaysWhichZeroItIs: + def _deps(self, *names): + return [{"dep_name": n, "ecosystem": "python"} for n in names] + + def test_unmatched_is_a_list_of_names_not_a_verdict(self): + a = ds.assess(self._deps("psycopg2", "left-pad"), egeria_types={"PostgreSQL Server": True}, + egeria_check="checked") + assert [m.technology.name for m in a.matches] == ["PostgreSQL"] + assert a.unmatched == ["left-pad"] + assert a.total_dependencies == 2 + + def test_egeria_not_consulted_reads_unchecked_not_absent(self): + a = ds.assess(self._deps("psycopg2"), egeria_types=None, egeria_check="unreachable", + egeria_check_detail="ConnectionError") + assert a.matches[0].egeria_state == "unchecked", ( + "Egeria was not reached and the technology reads as absent — 'could " + "not check' and 'not there' are different facts") + assert a.egeria_check == "unreachable" + + def test_egeria_reached_and_type_missing_reads_absent(self): + a = ds.assess(self._deps("psycopg2"), egeria_types={"PostgreSQL Server": False}, + egeria_check="checked") + assert a.matches[0].egeria_state == "absent" + + def test_a_technology_with_no_egeria_type_is_a_promotion_candidate(self): + techs = (ds.Technology("Redis", None, ("redis",)),) + a = ds.assess(self._deps("redis"), technologies=techs, egeria_types={}, egeria_check="checked") + assert a.matches[0].egeria_state == "no-type" + + def test_duplicate_dependency_names_count_once(self): + a = ds.assess(self._deps("redis", "Redis", "REDIS"), egeria_types=None) + assert a.total_dependencies == 1 + + def test_the_coverage_row_carries_the_egeria_check_at_the_top(self): + a = ds.assess(self._deps("psycopg2"), egeria_types=None, egeria_check="unreachable", + egeria_check_detail="timeout") + cov = [r for r in a.as_findings() if r["check_name"] == "coverage"][0] + assert cov["detail"]["egeria_check"] == "unreachable" + assert cov["confidence"] == 0, "an unchecked coverage row must not carry full confidence" + + def test_unmatched_names_are_not_persisted_one_per_row(self): + """800 dependencies would mean 800 rows saying 'unclassified'. They are + re-derived at read time instead.""" + a = ds.assess(self._deps(*[f"lib{i}" for i in range(200)], "psycopg2"), egeria_types=None) + rows = a.as_findings() + assert len(rows) == 2 # one technology + one coverage + assert rows[-1]["detail"]["unmatched_dependencies"] == 200 + + +class TestEgeriaLookupNeverRaises: + def test_no_linked_types_short_circuits(self): + present, state, _ = ds.egeria_technology_types_present([]) + assert present == {} and state == "checked" + + def test_an_unreachable_catalog_reports_unreachable(self, monkeypatch): + import resource_explorer.surveyors.egeria_tech_type_catalog as cat + + class Boom: + def __init__(self, *a, **k): pass + def connect(self): raise ConnectionError("no route to host") + + monkeypatch.setattr(cat, "EgeriaTechTypeCatalog", Boom) + present, state, detail = ds.egeria_technology_types_present(["PostgreSQL Server"]) + assert present is None and state == "unreachable" + assert "ConnectionError" in detail + + +# ── the surveyor, against the real registry ───────────────────────────────── + +class TestTheSurveyor: + @pytest.fixture + def slug(self, request): + import re + return "ds_" + re.sub(r"[^a-z0-9]+", "_", request.node.name.lower())[:48] + + @pytest.fixture + def reg(self, pg_registry, slug): + from resource_explorer.registry import Project + pg_registry.add(Project(slug=slug, display_name=slug, github_url=f"https://github.com/x/{slug}")) + return pg_registry + + def _run(self, reg, slug, monkeypatch, egeria=("unreachable", None)): + from resource_explorer.surveyors.sub_surveyors.dependency_support import DependencySupportSurveyor + state, present = egeria + monkeypatch.setattr(ds, "egeria_technology_types_present", + lambda names: (present, state, "stubbed")) + return DependencySupportSurveyor(project=reg.get(slug), registry=reg).run() + + def test_no_dependency_rows_is_unverified_not_zero_technologies(self, reg, slug, monkeypatch): + anns = self._run(reg, slug, monkeypatch) + assert len(anns) == 1 and anns[0].check_name == "coverage" + assert anns[0].json_properties.get("outcome") == "unverified" + rows = reg.query_findings(slug, "dependency_support") + assert rows and rows[0]["label"] == "no-dependencies" + + def test_matches_persist_and_read_back_with_detail(self, reg, slug, monkeypatch): + from resource_explorer.surveyors.repo_survey_definition_adapter import ( + _dependency_support_headline, _dependency_support_results) + reg.upsert_dependencies(slug, [ + {"dep_name": "psycopg2-binary", "dep_version": "2.9", "dep_type": "runtime", + "ecosystem": "python", "source_file": "requirements.txt"}, + {"dep_name": "left-pad", "dep_version": "1.0", "dep_type": "runtime", + "ecosystem": "javascript", "source_file": "package.json"}, + ]) + anns = self._run(reg, slug, monkeypatch, egeria=("checked", {"PostgreSQL Server": True})) + assert {a.check_name for a in anns} == {"technology", "coverage"} + + res = _dependency_support_results(reg, slug) + # The first version of the reader read `detail` where the registry + # hands back `detail_json`, and every technology came back `unchecked` + # with no dependencies. This is the check that would have caught it. + t = res["technologies"][0] + assert t["technology"] == "PostgreSQL" + assert t["dependencies"] == ["psycopg2-binary"], "detail_json was not parsed" + assert t["egeria_state"] == "present" + assert res["coverage"]["egeria_check"] == "checked" + assert res["unmatched"] == ["left-pad"] + h = _dependency_support_headline(reg, slug) + assert "1 technology indicated" in h["label"] and "1 dependencies unclassified" in h["label"] + + def test_egeria_down_shows_in_the_headline(self, reg, slug, monkeypatch): + from resource_explorer.surveyors.repo_survey_definition_adapter import _dependency_support_headline + reg.upsert_dependencies(slug, [{"dep_name": "psycopg2", "dep_version": "", "dep_type": "runtime", + "ecosystem": "python", "source_file": "r.txt"}]) + self._run(reg, slug, monkeypatch, egeria=("unreachable", None)) + h = _dependency_support_headline(reg, slug) + assert "could not be checked" in h["label"] and h["status"] == "warn" + + def test_never_run_is_a_status_envelope_not_content(self, reg, slug): + """live_read safety, same as every other reader here.""" + from resource_explorer.facts import _has_content + from resource_explorer.surveyors.repo_survey_definition_adapter import _dependency_support_results + res = _dependency_support_results(reg, "no_such_repo_at_all") + assert "_status" in res and not _has_content(res) + + +# ── wiring ────────────────────────────────────────────────────────────────── + +class TestItIsWiredEverywhere: + def test_step_kind_and_ownership(self): + from resource_explorer.surveyors.repo_survey_definition_adapter import ( + ANALYSIS_KINDS, REPO_ANALYSIS_STEP_MAP, STEP_REGISTRY) + assert "repo_dependency_support" in STEP_REGISTRY + assert REPO_ANALYSIS_STEP_MAP["dependency_support"] == ["repo_dependency_support"] + from resource_explorer.surveyors.repo_survey_definition_adapter import REPO_ANALYSIS_RESULTS_MAP + # render mode is the third positional of AnalysisKindResults; read it + # off the derived results map the frontend test also uses + assert "dependency_support" in REPO_ANALYSIS_RESULTS_MAP + + def test_the_step_fetches_nothing_so_discovery_is_honest(self): + from resource_explorer.surveyors.repo_survey_definition_adapter import STEP_REGISTRY + assert not (getattr(STEP_REGISTRY["repo_dependency_support"], "requires_resources", {}) or {}) + + def test_the_catalog_entry_is_discovery(self): + from resource_explorer.surveyors.analysis_catalog_reader import get_analyses + e = {a["id"]: a for a in get_analyses("repo", include_egeria_live=False)}["dependency_support"] + assert e["intent"] == "discovery" + + def test_the_question_now_cites_it_as_mixed(self): + from resource_explorer.surveyors.question_catalog_reader import get_questions + q = [q for q in get_questions() if q["question"].startswith("Do we already support")][0] + assert q["answering"]["kind"] == "mixed", "a machine starting point that a human confirms is `mixed`, not `human` and not `analysis`" + assert q["answering"]["analysis_ids"] == ["dependency_support"] + + def test_it_is_in_the_discovery_survey(self): + import csv + from pathlib import Path + p = Path(__file__).resolve().parent.parent / "docs" / "dr-egeria" / "repo_survey_types.csv" + rows = [r for r in csv.DictReader(p.open()) if r["step_key"] == "repo_dependency_support"] + assert {r["survey_group"] for r in rows} == {"RepoDiscoverySurvey"} + + def test_the_frontend_has_a_renderer(self): + from pathlib import Path + html = (Path(__file__).resolve().parent.parent / "resource_explorer" / "web" / "static" / "index.html").read_text() + assert "dependency_support: 'custom'" in html + assert "function _renderDependencySupportResults" in html + assert "dependency_support: _renderDependencySupportResults" in html + assert "not yet classified" in html, "the unmatched list must read as unclassified, never unsupported" + + +@pytest.mark.live_egeria +class TestAgainstLiveEgeria: + """Every `egeria_technology_type` in the mapping must name a type Egeria + actually holds — verified live, skipped (not passed) when Egeria is down.""" + + def test_every_linked_type_exists(self): + linked = sorted({t.egeria_technology_type for t in ds.load_mapping() if t.egeria_technology_type}) + present, state, detail = ds.egeria_technology_types_present(linked) + if state != "checked": + pytest.skip(f"Egeria not reachable: {detail}") + missing = sorted(n for n, ok in present.items() if not ok) + assert not missing, ( + f"these mapping entries link to Egeria technology types that do not exist: {missing}. " + f"Either the displayName is wrong or the type was removed.") From 72c5e496367e7b6fcd649605212d33eb6ac7aeb3 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sat, 12 Sep 2026 15:13:41 -0500 Subject: [PATCH 24/37] RE: vendored is a provenance claim, generated is not -- split the rule, say which, and drop the orphan collections Designer review of 2026-09-12, section 5, and two of the answered items. VENDORED_DIRS conflated dist/build/target/.git with node_modules and site-packages, so the rail said "vendored and not counted" about a repository's own build output. Two sets now -- VENDORED_DIRS (somebody else's code checked in) and GENERATED_DIRS (our own output) -- with SKIPPED_DIRS as the union every walk reads. The inventory column stores which (1/2), the summary reports both, and the rail sentence says "4,425 vendored and 12 generated, not counted". is_vendored() keeps its name and meaning. The short form the commit message promised and app.js never rendered -- "6,423 files . 4,425 vendored" -- now sits on the members rail heading. is_vendored_abs raised nothing on a path outside its root and returned False for every file; it raises. vendored_tops marked all of src/ not worthy when one src/x/node_modules/y.js existed; folders are judged by their own name. foss_scorecard and interface_surface carried their own copy of the reader's filter in raw SQL; they go through registry.get_file_inventory. documentation_coverage's headline: "40.4% of the public API documented" over a mostly-TypeScript repository read as a claim about the repository. It now says "40.4% of public Java and Python symbols carry a docstring (971 measured; other languages not measured)" -- the denominator was never the file inventory, as the review established. The six orphan egeria_workspaces_* collections (12,515 chunks, the vendored-citation hazard) were dropped on the owner's instruction after checking 69 registry columns for a reference; recorded in docs/vendored-guard.md rather than left to disappear. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- .../resource-explorer/docs/vendored-guard.md | 41 ++++++++++ .../ingestion/line_census.py | 2 +- .../resource_explorer/ingestion/vendored.py | 63 ++++++++++++---- .../resource_explorer/members.py | 28 +++++-- .../resource_explorer/registry.py | 27 +++++-- .../repo_survey_definition_adapter.py | 21 +++++- .../surveyors/sub_surveyors/foss_scorecard.py | 11 +-- .../sub_surveyors/interface_surface.py | 5 +- .../sub_surveyors/sub_resource_survey.py | 13 +++- .../resource_explorer/web/static/next/app.js | 3 +- .../resource-explorer/tests/test_vendored.py | 74 ++++++++++++++++++- 11 files changed, 239 insertions(+), 49 deletions(-) diff --git a/packages/resource-explorer/docs/vendored-guard.md b/packages/resource-explorer/docs/vendored-guard.md index a2fa763ca..0f1cf2a7f 100644 --- a/packages/resource-explorer/docs/vendored-guard.md +++ b/packages/resource-explorer/docs/vendored-guard.md @@ -89,3 +89,44 @@ introduced by #36 with no test on that branch — and the extra-path PDF walk checked vendored-ness against `local_root` when the extra directory lives outside it, so `is_vendored_abs` failed open and never excluded anything. Both now test against the extra directory itself. + +## Record: the orphan collections, dropped 2026-09-12 + +The retired slug `egeria_workspaces` (re-registered as `egeria_workspaces_git`) +left six pgvector collections that nothing searched: 12,515 chunks, 99% of +the JavaScript ones vendored TypeScript. The correction in PR #36's body was +that chat was **not** citing vendored code today — because these were the +only vendored chunks, and they were orphaned. Keeping them kept alive the +thing that was nearly a citation defect. Before the drop, every text column +in the registry that names a slug or a collection (69 columns) was checked +for a reference to the retired slug: none. + +``` +dropped at 2026-09-12T20:03:52+00:00 +egeria_workspaces_java_code 28 chunks -> dropped, exists now: False +egeria_workspaces_javascript_code 5,064 chunks -> dropped, exists now: False +egeria_workspaces_markdown_docs 5,873 chunks -> dropped, exists now: False +egeria_workspaces_pdfs 1,437 chunks -> dropped, exists now: False +egeria_workspaces_python_code 103 chunks -> dropped, exists now: False +egeria_workspaces_release_notes 10 chunks -> dropped, exists now: False +total 12,515 +remaining egeria_workspaces*: ['egeria_workspaces_git_java_code', 'egeria_workspaces_git_javascript_code', 'egeria_workspaces_git_markdown_docs', 'egeria_workspaces_git_pdfs', 'egeria_workspaces_git_python_code', 'egeria_workspaces_git_release_notes'] +``` + +Dropped by the project owner's instruction, via `MultiCollectionStore.drop_collection`, +recorded here rather than left to disappear quietly — the same instinct as +the PR-body correction. + +## The split, 2026-09-12 + +`VENDORED_DIRS` conflated generated with vendored — `dist`, `build`, +`target`, `.git` sat beside `node_modules`. The rail then said *vendored +and not counted* about a repository's own build output: a defensible number +under a wrong sentence. The rule is now two sets, `VENDORED_DIRS` (a +provenance claim) and `GENERATED_DIRS` (the repository's own output), with +`SKIPPED_DIRS` as their union for every walk. The inventory column stores +which (`1` vendored, `2` generated; rows indexed 2026-09-11 carry `1` for +either until re-indexed), the summary reports both, and the rail says +*4,425 vendored and 12 generated, not counted*. `is_vendored()` keeps its +name and its meaning — "should a measurement skip this?" — and is true for +both. diff --git a/packages/resource-explorer/resource_explorer/ingestion/line_census.py b/packages/resource-explorer/resource_explorer/ingestion/line_census.py index 8565e826e..83d04c5be 100644 --- a/packages/resource-explorer/resource_explorer/ingestion/line_census.py +++ b/packages/resource-explorer/resource_explorer/ingestion/line_census.py @@ -81,7 +81,7 @@ #: or `node_modules/` would inflate exactly the same way — and "how much code #: is there" answered with somebody else's library is a wrong answer, not a #: generous one. -from resource_explorer.ingestion.vendored import VENDORED_DIRS as _EXCLUDED_DIRS # noqa: E402 — one rule, shared since 2026-09-11 +from resource_explorer.ingestion.vendored import SKIPPED_DIRS as _EXCLUDED_DIRS # noqa: E402 — one rule, shared since 2026-09-11 def _is_excluded(path: Path, root: Path) -> bool: diff --git a/packages/resource-explorer/resource_explorer/ingestion/vendored.py b/packages/resource-explorer/resource_explorer/ingestion/vendored.py index a952bded2..c182051b7 100644 --- a/packages/resource-explorer/resource_explorer/ingestion/vendored.py +++ b/packages/resource-explorer/resource_explorer/ingestion/vendored.py @@ -26,32 +26,63 @@ from pathlib import Path -#: Directory names that mean "not this repository's own code". Matched on -#: any path segment, so `a/b/node_modules/c.js` is vendored and so is -#: `node_modules/c.js`. Kept deliberately short and conventional; a repo -#: with an unusual vendoring layout is a case for a per-repo rule, not for -#: growing this list until it matches everything. +#: Two sets, because they are two different claims (review, 2026-09-12). +#: VENDORED is provenance -- somebody else's code checked in. GENERATED is +#: this repository's own output -- build artefacts, caches, tool state -- +#: which is not source but is not somebody else's either. Every walk skips +#: both; the inventory records WHICH, so the rail can say "vendored" about +#: node_modules and "generated" about dist/ rather than calling a +#: repository's own build output vendored. Matched on any path segment, so +#: `a/b/node_modules/c.js` is vendored and so is `node_modules/c.js`. Kept +#: deliberately short and conventional; a repo with an unusual layout is a +#: case for a per-repo rule, not for growing these until they match +#: everything. VENDORED_DIRS = frozenset({ - ".git", ".hg", ".svn", - ".venv", "venv", "env", "virtualenv", "site-packages", "node_modules", "bower_components", "vendor", + ".venv", "venv", "env", "virtualenv", "site-packages", +}) +GENERATED_DIRS = frozenset({ + ".git", ".hg", ".svn", "__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".tox", "dist", "build", "target", "out", ".next", ".nuxt", ".idea", ".vscode", ".gradle", ".eggs", }) +#: Everything a walk over "this repository's code" skips. +SKIPPED_DIRS = VENDORED_DIRS | GENERATED_DIRS + +#: How the inventory column `vendored` spells the two. 0 is own code; a +#: row indexed before 2026-09-12 carries 1 for either kind. +OWN, VENDORED, GENERATED = 0, 1, 2 + + +def provenance(rel_path: str | Path) -> int: + """OWN, VENDORED or GENERATED for a path relative to the walk root. Only + DIRECTORIES on the path are tested, never the file's own name: a file + called `vendor` is not vendored and a file inside `vendor/` is. The + first matching segment from the root decides, so `vendor/build/x.js` + is vendored (somebody else's build output is still somebody else's).""" + for part in Path(rel_path).parts[:-1]: + if part in VENDORED_DIRS: + return VENDORED + if part in GENERATED_DIRS: + return GENERATED + return OWN def is_vendored(rel_path: str | Path) -> bool: - """True when any DIRECTORY on the path is a vendored name. The file's own - name is never tested, so a file called `vendor` is not vendored and a - file inside `vendor/` is.""" - parts = Path(rel_path).parts - return any(p in VENDORED_DIRS for p in parts[:-1]) + """True when the path is not this repository's own source -- vendored OR + generated. The name predates the split; every walk that asks "should I + measure this?" wants both answers to be no, so it keeps meaning that.""" + return provenance(rel_path) != OWN def is_vendored_abs(path: Path, root: Path) -> bool: - """The same test for an absolute path under a walk root.""" + """The same test for an absolute path under a walk root. A path that is + not under `root` is a caller bug -- the extra-docs PDF walk passed the + clone root for a directory outside it and this silently returned False + for every file -- so it raises rather than failing open.""" try: - return is_vendored(path.relative_to(root)) - except ValueError: - return False + rel = path.relative_to(root) + except ValueError as e: + raise ValueError(f"{path} is not under walk root {root}; pass the root the walk started from") from e + return is_vendored(rel) diff --git a/packages/resource-explorer/resource_explorer/members.py b/packages/resource-explorer/resource_explorer/members.py index a0f26d9ef..90e59d6a4 100644 --- a/packages/resource-explorer/resource_explorer/members.py +++ b/packages/resource-explorer/resource_explorer/members.py @@ -51,6 +51,7 @@ class MemberSet: groups: list = field(default_factory=list) note: str = "" source: str = "" # what was read: a table or the findings + inventory: str = "" # the short form: "6,423 files · 4,425 vendored" def to_dict(self) -> dict: return { @@ -58,7 +59,7 @@ def to_dict(self) -> dict: "total": self.total, "scope": self.scope, "scope_honoured": self.scope_honoured, "groups": [{"name": g.name, "count": g.count, "members": g.members, "truncated": g.truncated} for g in self.groups], - "note": self.note, "source": self.source, + "note": self.note, "source": self.source, "inventory": self.inventory, } @@ -154,15 +155,30 @@ def _symbol_members(registry, slug, scope, limit) -> MemberSet: # 2026-09-11 still counts vendored code as its own until re-indexed, and # the inventory summary is how the rail says so rather than hiding it. inv = registry.file_inventory_summary(slug) - provenance = (f"{inv['own']:,} of {inv['total']:,} files are this repository's own; " - f"{inv['vendored']:,} vendored and not counted." if inv["vendored"] - else f"{nfiles} files. Indexed {inv['indexed_at'][:10] or 'unknown'}; an index before " - "2026-09-11 counts vendored code as the repository's own until re-indexed.") + provenance = inventory_sentence(inv, nfiles) return MemberSet("api_structure", "symbol_count", "symbols, by language and file" + ("" if scope == "all" else " — public only"), int(total or 0), scope, True, groups, note=f"{provenance} `public` is inferred from naming conventions, not declared; a maintainer's marking would be better.", - source="project_code_symbols") + source="project_code_symbols", inventory=inv.get("short", "")) + + +def inventory_sentence(inv: dict, nfiles=None) -> str: + """The rail's sentence about the inventory, saying WHICH kind of + not-own. Vendored is a provenance claim (somebody else's code checked + in); generated is the repository's own build output. "4,425 vendored" + about dist/ was a wrong sentence with a defensible number.""" + kinds = [] + if inv.get("vendored"): + kinds.append(f"{inv['vendored']:,} vendored") + if inv.get("generated"): + kinds.append(f"{inv['generated']:,} generated") + if kinds: + return (f"{inv['own']:,} of {inv['total']:,} files are this repository's own; " + f"{' and '.join(kinds)}, not counted.") + n = inv.get("total") or nfiles or 0 + return (f"{n:,} files. Indexed {(inv.get('indexed_at') or '')[:10] or 'unknown'}; an index before " + "2026-09-11 counts vendored code as the repository's own until re-indexed.") def _symbols_in_file(registry, slug, file_path, scope, limit) -> list[dict]: diff --git a/packages/resource-explorer/resource_explorer/registry.py b/packages/resource-explorer/resource_explorer/registry.py index 9bac98d72..e05075548 100644 --- a/packages/resource-explorer/resource_explorer/registry.py +++ b/packages/resource-explorer/resource_explorer/registry.py @@ -3945,7 +3945,7 @@ def upsert_file_inventory( mode data (e.g. a purely local filesystem walk) simply omits it and every row gets file_mode='' (Assessment sub-resource cataloging plan, D9 Tier 1).""" - from resource_explorer.ingestion.vendored import is_vendored + from resource_explorer.ingestion.vendored import provenance slug = self._normalize_slug(slug) indexed_at = datetime.utcnow().isoformat() @@ -3959,27 +3959,40 @@ def upsert_file_inventory( "(project_slug, file_path, file_size_bytes, indexed_at, file_mode, vendored) " "VALUES (?, ?, ?, ?, ?, ?)", [ - (slug, path, size, indexed_at, modes_by_path.get(path, ""), 1 if is_vendored(path) else 0) + (slug, path, size, indexed_at, modes_by_path.get(path, ""), provenance(path)) for path, size in paths_with_sizes ], ) def file_inventory_summary(self, slug: str) -> dict: - """{total, own, vendored} — the honest number beside the misleading - one. `vendored` is 0 for a project indexed before 2026-09-11 until it - is re-indexed, and `indexed_at` says when that was.""" + """{total, own, vendored, generated, short} — the honest number beside + the misleading one, and WHICH kind of not-own: vendored is a + provenance claim, generated is the repository's own build output. + Both are 0 for a project indexed before 2026-09-11 until it is + re-indexed; one indexed between then and 2026-09-12 reports both + kinds as vendored. `indexed_at` says when.""" + from resource_explorer.ingestion.vendored import GENERATED, VENDORED slug = self._normalize_slug(slug) with self._conn() as conn: row = conn.execute( "SELECT COUNT(*) AS total, " - "SUM(CASE WHEN COALESCE(vendored, 0) = 1 THEN 1 ELSE 0 END) AS vendored, " + f"SUM(CASE WHEN COALESCE(vendored, 0) = {VENDORED} THEN 1 ELSE 0 END) AS vendored, " + f"SUM(CASE WHEN COALESCE(vendored, 0) = {GENERATED} THEN 1 ELSE 0 END) AS generated, " "MAX(indexed_at) AS indexed_at " "FROM project_file_inventory WHERE project_slug = ?", (slug,) ).fetchone() row = dict(row) if not isinstance(row, dict) else row total = int(row.get("total") or 0) vendored = int(row.get("vendored") or 0) - return {"total": total, "own": total - vendored, "vendored": vendored, "indexed_at": row.get("indexed_at") or ""} + generated = int(row.get("generated") or 0) + own = total - vendored - generated + parts = [f"{total:,} files"] + if vendored: + parts.append(f"{vendored:,} vendored") + if generated: + parts.append(f"{generated:,} generated") + return {"total": total, "own": own, "vendored": vendored, "generated": generated, + "short": " · ".join(parts), "indexed_at": row.get("indexed_at") or ""} def get_file_inventory(self, slug: str) -> list[str]: """Return all file paths from the stored inventory for a project.""" diff --git a/packages/resource-explorer/resource_explorer/surveyors/repo_survey_definition_adapter.py b/packages/resource-explorer/resource_explorer/surveyors/repo_survey_definition_adapter.py index 597771058..8d9b41959 100644 --- a/packages/resource-explorer/resource_explorer/surveyors/repo_survey_definition_adapter.py +++ b/packages/resource-explorer/resource_explorer/surveyors/repo_survey_definition_adapter.py @@ -3473,10 +3473,29 @@ def _documentation_headline(registry, slug: str) -> dict | None: if quality: parts.append(f"{quality.get('label')} documentation artifacts") if coverage: - parts.append(f"{coverage.get('label')} of the public API documented") + parts.append(_coverage_clause(coverage)) return {"label": ", ".join(parts), "status": "info"} +def _coverage_clause(coverage: dict) -> str: + """"40.4% of the public API documented" over a repository that is mostly + TypeScript reads as a claim about the repository. The finding's summary + already says what was counted -- "392 of 971 public symbols carry a + docstring (40.4%), measured over java, python." -- and the headline + dropped it (review, 2026-09-12). Say the languages and the count, so the + number stops overclaiming; a summary in an older shape falls back to the + bare phrase rather than inventing a scope.""" + import re + summary = coverage.get("summary") or "" + m = re.search(r"of ([\d,]+) public symbols .*?measured over ([^.]+)\.", summary) + if not m: + return f"{coverage.get('label')} of the public API documented" + langs = [x.strip() for x in m.group(2).split(",")] + names = " and ".join(l.capitalize() for l in langs) + return (f"{coverage.get('label')} of public {names} symbols carry a docstring " + f"({m.group(1)} measured; other languages not measured)") + + # The four GAP analyses. `noun` is chosen so the headline reads honestly when the # count is ZERO, which for these is the common and CORRECT case: "0 secret # matches" is a real result, whereas a noun like "issue" would make an absence diff --git a/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/foss_scorecard.py b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/foss_scorecard.py index e985ecaac..7992ba32c 100644 --- a/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/foss_scorecard.py +++ b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/foss_scorecard.py @@ -357,12 +357,13 @@ def run(self) -> list[Annotation]: # inventory containing no SBOM" are opposite answers, and a bare # empty list would make the first read as the second. paths = None - with self.registry._conn() as conn: - rows = conn.execute( - "SELECT file_path FROM project_file_inventory " - "WHERE project_slug = ? AND COALESCE(vendored, 0) = 0", (slug,)).fetchall() + # Through the registry, not a second copy of its filter: a + # reader that spells COALESCE(vendored, 0) = 0 itself is one + # more place to forget when the rule changes (#39 was about + # exactly that shape). + rows = self.registry.get_file_inventory(slug) if rows: - paths = [r["file_path"] for r in rows] + paths = list(rows) results = [] for check in CHECKS: diff --git a/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/interface_surface.py b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/interface_surface.py index 55bbf94ed..cdaef8ed6 100644 --- a/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/interface_surface.py +++ b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/interface_surface.py @@ -184,10 +184,7 @@ def run(self) -> list[Annotation]: out: list[Annotation] = [] try: slug = self.project.slug - with self.registry._conn() as conn: - paths = [r["file_path"] for r in conn.execute( - "SELECT file_path FROM project_file_inventory WHERE project_slug = ? AND COALESCE(vendored, 0) = 0", - (slug,)).fetchall()] + paths = self.registry.get_file_inventory(slug) # own files only; the registry holds the filter deps = [d.get("dep_name") for d in (self.registry.query_dependencies(slug) or [])] if not paths and not deps: diff --git a/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/sub_resource_survey.py b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/sub_resource_survey.py index cdf979425..5d5aa7018 100644 --- a/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/sub_resource_survey.py +++ b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/sub_resource_survey.py @@ -24,6 +24,7 @@ from datetime import datetime from resource_explorer.registry import Project, ProjectRegistry +from resource_explorer.ingestion.vendored import GENERATED_DIRS, VENDORED_DIRS from resource_explorer.step_outcome import from_upstream_table from resource_explorer.surveyors.base_surveyor import BaseSurveyor from resource_explorer.surveyors.survey_report import Annotation, ClassificationAnnotation @@ -255,23 +256,27 @@ def _build_findings(self, inventory: list[dict]) -> list[dict]: # Depth-1 folders. folder_counts: dict[str, int] = {} - vendored_tops: set[str] = set() for entry in inventory: path = entry["file_path"].replace("\\", "/") if "/" not in path: continue top = path.split("/", 1)[0] folder_counts[top] = folder_counts.get(top, 0) + 1 - if entry.get("vendored"): - vendored_tops.add(top) folder_entries: dict[str, dict] = {} for folder, count in sorted(folder_counts.items()): - if folder in vendored_tops: + # By the folder's own NAME. The first version marked a top folder + # vendored when ANY file under it was -- one src/x/node_modules/ + # y.js made all of src/ "not worthy · vendored" (review, + # 2026-09-12). A folder that CONTAINS vendored code is still the + # repository's own folder. + if folder in VENDORED_DIRS: # Somebody else's library checked in. Listed, and said why — # a promotion candidate this is not, but a fact about the # repository it is. worthy, reason = False, "vendored" + elif folder in GENERATED_DIRS: + worthy, reason = False, "generated" elif count < _FOLDER_MIN_FILES: worthy, reason = False, "too_small" elif count > _FOLDER_MAX_FILES: diff --git a/packages/resource-explorer/resource_explorer/web/static/next/app.js b/packages/resource-explorer/resource_explorer/web/static/next/app.js index aa19b85ef..345bb9fdf 100644 --- a/packages/resource-explorer/resource_explorer/web/static/next/app.js +++ b/packages/resource-explorer/resource_explorer/web/static/next/app.js @@ -3895,7 +3895,8 @@ async function openMembers({ slug, analysisId, metric = '', title = '' }) { out.innerHTML = `
    Members - ${data.total} · ${esc(data.title)} + ${data.total} · ${esc(data.title)}${ + data.inventory ? ` · ${tnum(esc(data.inventory))}` : ''}
    diff --git a/packages/resource-explorer/tests/test_vendored.py b/packages/resource-explorer/tests/test_vendored.py index 97f4b060b..c976bb0e1 100644 --- a/packages/resource-explorer/tests/test_vendored.py +++ b/packages/resource-explorer/tests/test_vendored.py @@ -14,7 +14,8 @@ import pytest -from resource_explorer.ingestion.vendored import VENDORED_DIRS, is_vendored, is_vendored_abs +from resource_explorer.ingestion.vendored import (GENERATED, GENERATED_DIRS, OWN, SKIPPED_DIRS, VENDORED, + VENDORED_DIRS, is_vendored, is_vendored_abs, provenance) from resource_explorer.registry import Project, ProjectRegistry @@ -29,7 +30,7 @@ def test_any_directory_segment_counts_and_the_filename_never_does(self): def test_the_line_census_reads_the_same_set(self): from resource_explorer.ingestion import line_census - assert line_census._EXCLUDED_DIRS is VENDORED_DIRS + assert line_census._EXCLUDED_DIRS is SKIPPED_DIRS def test_abs_form_is_relative_to_the_walk_root(self, tmp_path): (tmp_path / "node_modules").mkdir() @@ -37,6 +38,56 @@ def test_abs_form_is_relative_to_the_walk_root(self, tmp_path): assert is_vendored_abs(f, tmp_path) assert not is_vendored_abs(tmp_path / "x.js", tmp_path) + def test_a_path_outside_the_root_is_a_caller_bug_not_a_no(self, tmp_path): + """It failed open: the extra-docs PDF walk passed the clone root for + a directory outside it and every file came back "not vendored".""" + with pytest.raises(ValueError): + is_vendored_abs(tmp_path / "elsewhere" / "node_modules" / "x.js", tmp_path / "clone") + + +class TestVendoredIsNotGenerated: + """Two claims, two sets (review, 2026-09-12): vendored is provenance -- + somebody else's code checked in -- and generated is the repository's own + build output. Every walk skips both; the inventory records which.""" + + def test_the_sets_are_disjoint_and_together_are_what_walks_skip(self): + assert not (VENDORED_DIRS & GENERATED_DIRS) + assert SKIPPED_DIRS == VENDORED_DIRS | GENERATED_DIRS + assert "node_modules" in VENDORED_DIRS and "dist" in GENERATED_DIRS + + def test_provenance_names_the_kind(self): + assert provenance("node_modules/x/y.js") == VENDORED + assert provenance("dist/bundle.js") == GENERATED + assert provenance("src/app.py") == OWN + assert provenance("vendor/lib/build/out.js") == VENDORED # somebody else's build output is still theirs + assert provenance("build/vendor.js") == GENERATED # a FILE named vendor under our build dir + assert is_vendored("dist/x") and is_vendored("vendor/x") and not is_vendored("src/x") + + def test_the_summary_and_the_rail_say_which(self, db): + from resource_explorer.members import inventory_sentence + db.upsert_file_inventory("p", [("src/a.py", 1), ("node_modules/t/b.js", 1), ("node_modules/t/c.js", 1), ("dist/d.js", 1)]) + inv = db.file_inventory_summary("p") + assert (inv["total"], inv["own"], inv["vendored"], inv["generated"]) == (4, 1, 2, 1) + assert inv["short"] == "4 files · 2 vendored · 1 generated" + assert inventory_sentence(inv) == "1 of 4 files are this repository's own; 2 vendored and 1 generated, not counted." + # the other branch: an inventory that predates the flag + assert inventory_sentence({"total": 9, "own": 9, "vendored": 0, "generated": 0, "indexed_at": "2026-09-01T00:00:00"}) == ( + "9 files. Indexed 2026-09-01; an index before 2026-09-11 counts vendored code as the repository's own until re-indexed.") + # and the readers still see only own code + assert db.get_file_inventory("p") == ["src/a.py"] + + +class TestFoldersAreJudgedByTheirOwnName: + """One `src/x/node_modules/y.js` marked all of `src` not worthy · + vendored. A folder that contains vendored code is still ours.""" + + def test_a_folder_containing_vendored_code_is_not_itself_vendored(self): + from resource_explorer.surveyors.sub_surveyors import sub_resource_survey as srs + src = (srs.__file__ and open(srs.__file__, encoding="utf-8").read()) + assert "vendored_tops" not in src + assert "if folder in VENDORED_DIRS" in src and "elif folder in GENERATED_DIRS" in src + + @pytest.fixture def db(tmp_path): @@ -49,8 +100,8 @@ class TestInventoryRecordsProvenance: def test_vendored_files_are_kept_marked_and_excluded_from_readers(self, db): db.upsert_file_inventory("p", [("src/a.py", 10), ("node_modules/t.js", 99), ("docs/x.md", 5)]) # recorded, not dropped - assert db.file_inventory_summary("p") == {"total": 3, "own": 2, "vendored": 1, - "indexed_at": db.file_inventory_summary("p")["indexed_at"]} + inv = db.file_inventory_summary("p") + assert (inv["total"], inv["own"], inv["vendored"], inv["generated"]) == (3, 2, 1, 0) # readers see the repository's own files by default assert sorted(db.get_file_inventory("p")) == ["docs/x.md", "src/a.py"] @@ -275,3 +326,18 @@ def test_a_file_entry_still_works(self, tmp_path): from resource_explorer.ingestion.pipeline import IngestionPipeline f = tmp_path / "README.md"; f.write_text("hi\n") assert IngestionPipeline._local_files_for_paths(object(), [("readme", f)], [".md"]) == [("readme", "hi\n")] + + +class TestDocumentationCoverageSaysWhatItCounted: + """"40.4% of the public API documented" over a mostly-TypeScript repo + reads as a claim about the repository; only Python and Java are + measured for docstrings. The headline now says so (review, 2026-09-12).""" + + def test_the_headline_names_the_languages_and_the_count(self): + from resource_explorer.surveyors.repo_survey_definition_adapter import _coverage_clause + c = {"label": "40.4%", "summary": "392 of 971 public symbols carry a docstring (40.4%), measured over java, python. Not counted for javascript — ..."} + assert _coverage_clause(c) == "40.4% of public Java and Python symbols carry a docstring (971 measured; other languages not measured)" + + def test_an_older_summary_shape_falls_back_rather_than_inventing_a_scope(self): + from resource_explorer.surveyors.repo_survey_definition_adapter import _coverage_clause + assert _coverage_clause({"label": "12%", "summary": "something else"}) == "12% of the public API documented" From f643b9d378f3845752e5807ae412836763493ee2 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sat, 12 Sep 2026 15:22:24 -0500 Subject: [PATCH 25/37] RE /next: the journal and Disposition -- one trail format, a picker with its trail, the landing named, the audience whole Designer review of 2026-09-12, section 4. Two trail formats for one fact: app.js rendered the spec's line and worklist.js a two-line layout of its own, with no tnum on the column that exists to align. One formatter in format.js now serves both, and the reason sits in ink beside the verdict it explains rather than muted. "Changed N time(s)" is a real plural, and the pane's trail carries the count the matrix had. The Disposition pane had Verdicts above a trail with no way to add to it; the only picker was the header popover, a click away and above the heading. The picker is one function, shown in both places with its trail. The journal's confirmation named the slug, not the list, and removed itself after six seconds -- the only record the writer gets. It names the list ("Suggested to Data Expert", returned by the server alongside the slug) and stays until the next write. The work-list view replaced the sub-tab rail with "Work list . back", breaking the identical-order rule on that one view. The rail is present and its per-resource tabs marked as such. The journal's audiences came from list_perspectives(), a deliberate subset of what can narrow a filter. An audience is the whole vocabulary: /api/analyses/perspectives?scope=all returns EGERIA_PERSPECTIVES and the journal reads that. Same twelve today; the point is which list it follows when they diverge. Also: a guard that the committed question_catalog.yaml is what its CSV generates -- a peer's hand-edit passed 144 tests today because nothing checked it; the survey definitions have had the equivalent since their generator landed. Made to fail on purpose before trusting it. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- .../resource_explorer/journal.py | 8 +- .../resource_explorer/web/routes/analyses.py | 8 +- .../resource_explorer/web/static/next/app.js | 128 +++++++++++------- .../web/static/next/format.js | 21 +++ .../web/static/next/worklist.js | 19 ++- .../resource_explorer/web/static/re-api.js | 4 + .../resource-explorer/tests/test_journal.py | 22 ++- .../tests/test_next_journal_fidelity.py | 63 +++++++++ .../test_question_catalog_generator_guard.py | 34 +++++ 9 files changed, 246 insertions(+), 61 deletions(-) create mode 100644 packages/resource-explorer/tests/test_next_journal_fidelity.py create mode 100644 packages/resource-explorer/tests/test_question_catalog_generator_guard.py diff --git a/packages/resource-explorer/resource_explorer/journal.py b/packages/resource-explorer/resource_explorer/journal.py index 65a804051..7f8247196 100644 --- a/packages/resource-explorer/resource_explorer/journal.py +++ b/packages/resource-explorer/resource_explorer/journal.py @@ -128,16 +128,20 @@ def _route(self, target: str, entity_type: str, entity_slug: str, author: str, b adding a second row — set_member upserts.""" wl = WorkLists(self.registry) slug = audience_slug(target) + name = f"Suggested to {target}" existing = wl.get(slug) if existing is None: - wl.create(f"Suggested to {target}", [entity_slug], slug=slug, entity_type=entity_type, + wl.create(name, [entity_slug], slug=slug, entity_type=entity_type, created_by=author, derived_from="journal", description="Resources someone wrote about and suggested to this audience. " "Each member's rationale is their journal entry.", rationale=body) else: wl.set_member(slug, entity_slug, rationale=body) - return {"target": target, "work_list": slug} + name = (existing.get("display_name") if isinstance(existing, dict) + else getattr(existing, "display_name", None)) or name + # The NAME is where it landed; the slug is how the server finds it. + return {"target": target, "work_list": slug, "name": name} def suggested_targets(self, entity_type: str, entity_slug: str) -> list[str]: """Everyone this resource has ever been suggested to.""" diff --git a/packages/resource-explorer/resource_explorer/web/routes/analyses.py b/packages/resource-explorer/resource_explorer/web/routes/analyses.py index f12e86061..d8bd3336d 100644 --- a/packages/resource-explorer/resource_explorer/web/routes/analyses.py +++ b/packages/resource-explorer/resource_explorer/web/routes/analyses.py @@ -117,12 +117,18 @@ def delete_annotation_type(type_name: str) -> dict: @router.get("/perspectives") -def list_perspectives_route() -> list[str]: +def list_perspectives_route(scope: str = "catalog") -> list[str]: """Distinct perspective values actually in the catalog — backs the UI's perspective selector so it's never a hardcoded, silently-stale list. + `scope=all` returns the whole Egeria vocabulary instead: an AUDIENCE + (the journal's "suggest to") is everyone who exists, not the subset + something is tagged with today. NOTE: declared before /{resource_type} deliberately — Starlette matches routes in declaration order, so a literal path after a path-param catch-all at the same position would never be reached.""" + if scope == "all": + from resource_explorer.surveyors.analysis_catalog_reader import EGERIA_PERSPECTIVES + return list(EGERIA_PERSPECTIVES) return list_perspectives() diff --git a/packages/resource-explorer/resource_explorer/web/static/next/app.js b/packages/resource-explorer/resource_explorer/web/static/next/app.js index 847379224..a3b0b1838 100644 --- a/packages/resource-explorer/resource_explorer/web/static/next/app.js +++ b/packages/resource-explorer/resource_explorer/web/static/next/app.js @@ -25,7 +25,7 @@ // and it goes when the experiment goes. import { listWorkLists, openWorkList, saveAsWorkList, openDialog, closeCellDetail, CELL } from '/static/next/worklist.js'; -import { ago, whenMs } from '/static/next/format.js'; +import { ago, whenMs, verdictLineHtml, changedTimesHtml } from '/static/next/format.js'; import { ApiError, VALID_DISPOSITIONS, @@ -56,6 +56,7 @@ import { listInvestigationMembers, listInvestigations, listPerspectives, + listAllPerspectives, listProjects, listRfas, pollActivity, @@ -2517,19 +2518,43 @@ async function renderDispositionHistory(githubUrl, target = 'disposition-history el.textContent = 'No disposition has been recorded for this repo.'; return; } - el.innerHTML = `
    History
    ` - + rows.map((r) => { - // The field is `decided_at` — verified against the endpoint, not - // guessed. `decided_by` is often empty; it is shown only when set, - // rather than rendering an empty attribution. - const when = r.decided_at || ''; - const rel = ago(when); - return `
    ${esc(r.disposition || '—')} - ${rel ? ` · ${esc(rel)}` : ''} - ${when ? ` (${esc(String(when).slice(0, 10))})` : ''} - ${r.decided_by ? ` · ${esc(r.decided_by)}` : ''} - ${r.reason ? ` · ${esc(r.reason)}` : ''}
    `; - }).join(''); + // Oldest first, the same as the matrix's trail: the point is the + // sequence. One formatter for both views -- they had drifted into two + // layouts of one fact. + const ordered = [...rows].sort((a, b) => whenMs(a.decided_at) - whenMs(b.decided_at)); + el.innerHTML = `
    History${ + ordered.length > 1 ? ` · ${changedTimesHtml(ordered.length - 1)}` : ''}
    ` + + ordered.map((r) => `
    ${verdictLineHtml(r, esc)}
    `).join(''); +} + +/** The verdict picker, as one thing: the popover and the Disposition pane + * both show it, and a trail with no way to add to it was the drawing's + * complaint about the pane. `onSet` runs after a successful write. */ +function dispositionPickerHtml(p) { + return `
    + Set disposition + ${VALID_DISPOSITIONS.map((d) => ``).join('')} +
    `; +} + +function wireDispositionPicker(host, p, { note, onSet }) { + host.querySelectorAll('[data-disp]').forEach((b) => b.addEventListener('click', async () => { + const value = b.dataset.disp; + note('Saving…'); + try { + await setDisposition(p.github_url, value); + p.disposition = value; + renderSidebar(); + await onSet(value); + } catch (err) { + note(`Not saved: ${esc(err.message)}`); + } + })); } /** The header's three write paths. `hide` is reversible, `disposition` is a @@ -2550,15 +2575,7 @@ function bindResourceHeader() { return; } slot.innerHTML = ` -
    - Set disposition - ${VALID_DISPOSITIONS.map((d) => ``).join('')} -
    + ${dispositionPickerHtml(p)}
    Loading history…
    `; // The HISTORY, alongside the picker. It exists in the current UI and // nowhere in /next, and it is the only place the SEQUENCE of verdicts is @@ -2566,21 +2583,12 @@ function bindResourceHeader() { // current value cannot say that something was abandoned and then picked // back up. renderDispositionHistory(p.github_url, 'disposition-history-popover'); - slot.querySelectorAll('[data-disp]').forEach((b) => b.addEventListener('click', async () => { - const value = b.dataset.disp; - note('Saving…'); - try { - await setDisposition(p.github_url, value); - p.disposition = value; - renderSidebar(); - el.innerHTML = ''; // rebuilt below by loadPane - await loadPane(); - $('resource-action').innerHTML = - `
    Disposition is now ${esc(value)}.
    `; - } catch (err) { - note(`Not saved: ${esc(err.message)}`); - } - })); + wireDispositionPicker(slot, p, { note, onSet: async (value) => { + el.innerHTML = ''; // rebuilt below by loadPane + await loadPane(); + $('resource-action').innerHTML = + `
    Disposition is now ${esc(value)}.
    `; + } }); }); el.querySelector('[data-act="hide"]')?.addEventListener('click', async () => { @@ -2718,6 +2726,7 @@ async function loadDispositionPane() {
    ${resourceHeaderHtml(slug)}
    Verdicts
    +
    Loading history…
    Journal @@ -2728,8 +2737,23 @@ async function loadDispositionPane() { bindSubTabs(); bindResourceHeader(); const project = state.projects.find((x) => x.slug === slug); - if (project?.github_url) renderDispositionHistory(project.github_url); - else $('disposition-history').textContent = 'No GitHub URL, so no disposition can be keyed to this resource.'; + if (project?.github_url) { + // The picker WITH its trail, as drawn -- the header popover is a click + // away and above the heading; this is where a verdict is considered. + const mountPicker = () => { + const pick = $('disposition-picker'); + if (!pick) return; + pick.innerHTML = dispositionPickerHtml(project); + wireDispositionPicker(pick, project, { + note: (html) => { const h = $('disposition-history'); if (h) h.innerHTML = html; }, + onSet: async () => { mountPicker(); await renderDispositionHistory(project.github_url); }, + }); + }; + mountPicker(); + renderDispositionHistory(project.github_url); + } else { + $('disposition-history').textContent = 'No GitHub URL, so no disposition can be keyed to this resource.'; + } renderJournalWrite(slug); await renderJournalEntries(slug); } @@ -2738,7 +2762,11 @@ function renderJournalWrite(slug) { const host = $('journal-write'); if (!host) return; const who = (state.me && (state.me.user_id || state.me.username || state.me.egeria_user)) || ''; - const perspectives = (state.perspectives || []).map((p) => p.name || p.id || p).filter(Boolean); + // An AUDIENCE is the whole Egeria vocabulary, not the filter row's + // subset: someone is a Data Owner whether or not any analysis is tagged + // with it today. (The two are the same twelve at the moment; the point + // is which list this one follows when they diverge.) + const perspectives = (state.allPerspectives || state.perspectives || []).map((p) => p.name || p.id || p).filter(Boolean); host.innerHTML = ` @@ -2767,12 +2795,17 @@ function renderJournalWrite(slug) { $('journal-body').value = ''; $('journal-person').value = ''; host.querySelectorAll('[data-suggest]').forEach((c) => { c.checked = false; }); b.disabled = false; b.textContent = 'Write'; - const where = (out.work_lists || []).map((w) => w.work_list).join(', '); + // Where it landed, by the list's NAME -- "Suggested to Data Expert" + // is what was created; the slug is how the server finds it. And it + // stays until the next write replaces it: this is the only record + // the writer gets, and six seconds is not long enough to read one. + const where = (out.work_lists || []).map((w) => w.name || w.work_list).join(', '); + host.querySelector('[data-journal-note]')?.remove(); const note = document.createElement('div'); - note.className = 'mt-s1 text-provenance text-ink-muted'; - note.textContent = where ? `written · suggested — now in ${where}` : 'written'; + note.className = 'mt-s1 text-provenance text-ink'; + note.setAttribute('data-journal-note', '1'); + note.textContent = where ? `written · suggested — now in “${where}”` : 'written'; host.appendChild(note); - setTimeout(() => note.remove(), 6000); await renderJournalEntries(slug); } catch (err) { b.disabled = false; @@ -4568,6 +4601,7 @@ async function loadPane() { try { await openWorkList({ el, + subTabs: SUB_TABS.map((t) => ({ id: t.id, label: t.label })), stage: state.stage, perspectives: state.activePerspectives, projects: state.projects, @@ -5671,7 +5705,7 @@ async function start() { state.investigation = currentInvestigation(); const [me, projects, perspectives, activity, rfas, groups, investigations, - workLists, analyses] = + workLists, analyses, allPerspectives] = await Promise.allSettled([ getMe(), // The FULL list — every disposition, hidden included — because the @@ -5680,10 +5714,12 @@ async function start() { listProjects({ includeIgnored: true, includeHidden: true }), listPerspectives(), listActivity(ACTIVITY_LIMIT), listRfas(), listGroups(), listInvestigations(), listWorkLists(), listAnalyses('repo'), + listAllPerspectives(), ]); if (me.status === 'fulfilled') state.me = me.value; if (perspectives.status === 'fulfilled') state.perspectives = perspectives.value || []; + if (allPerspectives.status === 'fulfilled') state.allPerspectives = allPerspectives.value || []; // Both endpoints PAGE. A returned length equal to the limit is a page // that filled, not a total — rendering it as one would print an exact // figure that is exactly wrong, and nothing about the number would look diff --git a/packages/resource-explorer/resource_explorer/web/static/next/format.js b/packages/resource-explorer/resource_explorer/web/static/next/format.js index 5c2f45f3c..e2c490b4a 100644 --- a/packages/resource-explorer/resource_explorer/web/static/next/format.js +++ b/packages/resource-explorer/resource_explorer/web/static/next/format.js @@ -40,3 +40,24 @@ export function daysSince(iso) { if (Number.isNaN(then)) return null; return (Date.now() - then) / 86400000; } + +/** One disposition verdict, one line, the same in both views: + * `tracking · 32d ago (2026-08-11) · who · reason`. The reason sits in + * ink beside the word it explains -- a trail exists for the reasons, and + * rendering them dimmer than the one-word verdict says the opposite. + * `esc` is passed in because this module has no DOM helpers of its own. */ +export function verdictLineHtml(r, esc) { + const when = r.decided_at || ''; + const rel = ago(when); + return `${esc(r.disposition || '—')}${ + rel ? ` · ${esc(rel)}` : ''}${ + when ? ` (${esc(String(when).slice(0, 10))})` : ''}${ + r.decided_by ? ` · ${esc(r.decided_by)}` : ''}${ + r.reason ? ` · ${esc(r.reason)}` : ''}`; +} + +/** "changed once" / "changed 3 times" -- never `time(s)`. */ +export function changedTimesHtml(n) { + if (n < 1) return ''; + return n === 1 ? 'changed once' : `changed ${n} times`; +} diff --git a/packages/resource-explorer/resource_explorer/web/static/next/worklist.js b/packages/resource-explorer/resource_explorer/web/static/next/worklist.js index 2bbeb36fb..24d41ccc6 100644 --- a/packages/resource-explorer/resource_explorer/web/static/next/worklist.js +++ b/packages/resource-explorer/resource_explorer/web/static/next/worklist.js @@ -38,7 +38,7 @@ import { publishWorkList, setDisposition, } from '/static/re-api.js'; -import { ago, daysSince } from '/static/next/format.js'; +import { ago, daysSince, verdictLineHtml, changedTimesHtml } from '/static/next/format.js'; /* ── Cell state ───────────────────────────────────────────────────────── * @@ -172,8 +172,14 @@ export async function renderWorkListPane(ctx) { const wl = grid.workList; if (!wl) return; + // The sub-tab rail keeps its order here too (design rule: identical + // across every stage; grey out what a view lacks, never remove it). A + // work list is a view over MANY resources, so the per-resource tabs are + // present and marked, and the title says how to reach them. el.innerHTML = `
    + ${(ctx.subTabs || []).map((t) => `${esc(t.label)}`).join('')} Work list
    @@ -614,17 +620,10 @@ async function openResourceDetail(member) {
      ${ordered.map((r, i) => `
    1. ${i + 1} - - ${esc(r.disposition || '—')} - ${r.reason ? ` — ${esc(r.reason)}` : ''} - ${ - r.decided_at ? `${esc(ago(r.decided_at))} · ${esc(String(r.decided_at).slice(0, 10))}` : 'undated'}${ - r.decided_by ? ` · ${esc(r.decided_by)}` : ''} - + ${verdictLineHtml(r, esc)}
    2. `).join('')}
    - ${ordered.length > 1 ? `

    Changed - ${ordered.length - 1} time(s).

    ` : ''}`; + ${ordered.length > 1 ? `

    ${changedTimesHtml(ordered.length - 1)}.

    ` : ''}`; } /** Name the cause of an unreadable cell, from the server's own message. diff --git a/packages/resource-explorer/resource_explorer/web/static/re-api.js b/packages/resource-explorer/resource_explorer/web/static/re-api.js index d96ce35b5..f50c0fb87 100644 --- a/packages/resource-explorer/resource_explorer/web/static/re-api.js +++ b/packages/resource-explorer/resource_explorer/web/static/re-api.js @@ -120,6 +120,10 @@ export const listFilesystems = () => get('/api/filesystems/'); export const listPerspectives = () => cached('perspectives', () => get('/api/analyses/perspectives')); +/** The whole vocabulary -- an audience, not a filter. */ +export const listAllPerspectives = () => + cached('perspectives-all', () => get('/api/analyses/perspectives?scope=all')); + export const listAnalyses = (resourceType, { intent, perspective } = {}) => { const qs = new URLSearchParams(); if (intent) qs.set('intent', intent); diff --git a/packages/resource-explorer/tests/test_journal.py b/packages/resource-explorer/tests/test_journal.py index 4fe1565f2..4a534febc 100644 --- a/packages/resource-explorer/tests/test_journal.py +++ b/packages/resource-explorer/tests/test_journal.py @@ -51,6 +51,9 @@ def test_a_suggestion_lands_in_a_list_named_for_its_audience(self, registry): body="Worth using for anyone building against the Egeria API from Python.", suggest_to=["Data Expert", "App/AI Builder"]) assert [w["work_list"] for w in out["work_lists"]] == ["suggested-to-data-expert", "suggested-to-app-ai-builder"] + # The NAME travels back too: the UI says where it landed, and a + # machine slug is not where it landed (review, 2026-09-12). + assert [w["name"] for w in out["work_lists"]] == ["Suggested to Data Expert", "Suggested to App/AI Builder"] wl = WorkLists(registry).get("suggested-to-data-expert") assert wl["display_name"] == "Suggested to Data Expert" assert wl["derived_from"] == "journal" and wl["created_by"] == "peterprofile" @@ -60,7 +63,8 @@ def test_a_suggestion_lands_in_a_list_named_for_its_audience(self, registry): def test_a_second_suggestion_to_the_same_audience_updates_not_duplicates(self, registry): j = Journal(registry) j.write("repo", "p", author="a", body="first note", suggest_to=["Consumer"]) - j.write("repo", "p", author="b", body="second note, better", suggest_to=["Consumer"]) + second = j.write("repo", "p", author="b", body="second note, better", suggest_to=["Consumer"]) + assert second["work_lists"][0]["name"] == "Suggested to Consumer" # the existing list's name, second time round j.write("repo", "q", author="b", body="a different resource", suggest_to=["Consumer"]) wl = WorkLists(registry).get("suggested-to-consumer") members = {m["entity_slug"]: m["rationale"] for m in wl["members"]} @@ -94,6 +98,20 @@ def test_author_is_the_signed_in_user_and_anonymous_is_refused(self, client, mon r = client.post("/api/journal/repo/p", json={"body": "Note the sole-contributor risk.", "suggest_to": ["Steward"]}) assert r.status_code == 200, r.text assert r.json()["author"] == "peterprofile" - assert r.json()["work_lists"] == [{"target": "Steward", "work_list": "suggested-to-steward"}] + assert r.json()["work_lists"] == [{"target": "Steward", "work_list": "suggested-to-steward", "name": "Suggested to Steward"}] listing = client.get("/api/journal/repo/p").json() assert len(listing["entries"]) == 1 and listing["suggested_to"] == ["Steward"] + + +class TestTheAudienceIsTheWholeVocabulary: + client = TestRoute.client # the same app, the same anonymous read + + """`list_perspectives()` is a deliberate subset -- what can narrow a + filter row. An audience is not a filter: someone is a Data Owner whether + or not any analysis is tagged with it today. `scope=all` returns the + vocabulary; the default stays the subset.""" + + def test_scope_all_returns_every_egeria_perspective(self, client): + from resource_explorer.surveyors.analysis_catalog_reader import EGERIA_PERSPECTIVES, list_perspectives + assert client.get("/api/analyses/perspectives?scope=all").json() == list(EGERIA_PERSPECTIVES) + assert client.get("/api/analyses/perspectives").json() == list_perspectives() diff --git a/packages/resource-explorer/tests/test_next_journal_fidelity.py b/packages/resource-explorer/tests/test_next_journal_fidelity.py new file mode 100644 index 000000000..3a021e266 --- /dev/null +++ b/packages/resource-explorer/tests/test_next_journal_fidelity.py @@ -0,0 +1,63 @@ +"""Pins from the designer's 2026-09-12 review, section 4 -- the journal and +Disposition: one trail format for one fact, a real plural, the reason in +ink, the landing named by the list's name, and the audience as the whole +vocabulary.""" +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +NEXT = Path(__file__).resolve().parents[1] / "resource_explorer" / "web" / "static" / "next" + + +class TestOneTrailFormat: + def test_both_views_use_the_shared_verdict_line(self): + app = (NEXT / "app.js").read_text(encoding="utf-8") + wl = (NEXT / "worklist.js").read_text(encoding="utf-8") + for src, name in ((app, "app.js"), (wl, "worklist.js")): + assert "verdictLineHtml" in src and "changedTimesHtml" in src, f"{name} renders its own trail" + assert "time(s)" not in src, f"{name} still says time(s)" + # and neither carries a second hand-rolled layout of the same fact + assert "block text-provenance text-ink-muted\">${\n r.decided_at" not in wl + + def test_the_work_list_view_keeps_the_sub_tab_rail(self): + wl = (NEXT / "worklist.js").read_text(encoding="utf-8") + assert "ctx.subTabs" in wl and "← back to questions" in wl + app = (NEXT / "app.js").read_text(encoding="utf-8") + assert re.search(r"subTabs: SUB_TABS\.map", app) + + def test_the_journal_note_names_the_list_and_stays(self): + app = (NEXT / "app.js").read_text(encoding="utf-8") + i = app.index("const where = (out.work_lists") + body = app[i:app.index("await renderJournalEntries(slug);", i)] + assert "w.name || w.work_list" in body + assert "setTimeout" not in body, "the only record the writer gets must not self-destruct" + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not installed") +class TestTheFormatter: + def _run(self, expr: str, tmp_path: Path): + mod = tmp_path / "format.mjs" + mod.write_text((NEXT / "format.js").read_text(encoding="utf-8"), encoding="utf-8") + script = (f"import {{ verdictLineHtml, changedTimesHtml }} from '{mod.as_uri()}';\n" + f"const esc = (s) => String(s);\nconsole.log(JSON.stringify({expr}));") + out = subprocess.run(["node", "--input-type=module", "-e", script], capture_output=True, text=True, check=True) + return json.loads(out.stdout) + + def test_changed_times_is_a_real_plural(self, tmp_path): + assert self._run("[changedTimesHtml(0), changedTimesHtml(1), changedTimesHtml(3)]", tmp_path) == [ + "", "changed once", 'changed 3 times'] + + def test_the_reason_sits_in_ink_beside_the_verdict(self, tmp_path): + html = self._run( + "verdictLineHtml({disposition:'tracking', decided_at:'2026-08-11T10:00:00Z', decided_by:'dan', reason:'worth watching'}, esc)", + tmp_path) + assert html.startswith('tracking') + assert '(2026-08-11)' in html and '· dan' in html + assert 'worth watching' in html + assert 'text-ink-muted' not in html diff --git a/packages/resource-explorer/tests/test_question_catalog_generator_guard.py b/packages/resource-explorer/tests/test_question_catalog_generator_guard.py new file mode 100644 index 000000000..6210d1341 --- /dev/null +++ b/packages/resource-explorer/tests/test_question_catalog_generator_guard.py @@ -0,0 +1,34 @@ +"""The question catalog YAML is generated from its CSV and says so in its +own header. Nothing checked it: a hand-edit to the YAML passed 144 tests +on 2026-09-12 (caught in review of a peer's branch, not by the suite). The +survey definitions have had this guard since their generator landed -- +test_survey_definition_generator_guard -- and this is the same shape for +the catalog: a clean tree regenerates to byte-identical output, so a YAML +that drifts from its CSV fails here and names the fix. +""" +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SCRIPT = ROOT / "scripts" / "csv_to_question_catalog_yaml.py" +CSV = ROOT / "docs" / "dr-egeria" / "resource_questions.csv" +YAML = ROOT / "resource_explorer" / "configdata" / "question_catalog.yaml" + + +def test_the_committed_catalog_is_what_its_csv_generates(tmp_path): + out = tmp_path / "question_catalog.yaml" + r = subprocess.run([sys.executable, str(SCRIPT), str(CSV), "--output", str(out)], + capture_output=True, text=True, timeout=120) + assert r.returncode == 0, r.stdout + r.stderr + committed = YAML.read_text(encoding="utf-8") + generated = out.read_text(encoding="utf-8") + assert committed == generated, ( + "resource_explorer/configdata/question_catalog.yaml does not match what " + "docs/dr-egeria/resource_questions.csv generates. Edit the CSV, then run\n" + f" python scripts/csv_to_question_catalog_yaml.py {CSV.relative_to(ROOT)} " + f"--output {YAML.relative_to(ROOT)}\n" + "-- never the YAML by hand." + ) From c0689376fed4c1347a4a3e1a7549a84c36fe52a7 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sat, 12 Sep 2026 15:28:10 -0500 Subject: [PATCH 26/37] RE compiler: the yes/no sentence leaves production after run 9's within-run A/B; kept as a variant Run 9 (468 rows, three conditions answered back-to-back per question, schedules paused, rubric v4, replay 156/156 in both compiled arms) measured PR #38's sentence on its own: paired against the same wording without it, answers_question -0.06, supported claims -0.58 (32 up / 53 down), median answer 114 vs 181 chars, bare one-word answers 15 vs 10. Terser on every kind of question, not only yes/no ones. Two mechanisms, separated: instructions are a REQUIRED section, so the sentence's 202 characters came out of the evidence - one section dropped a rung in 135 of 156 compiles (1275 vs 1396 sections at FULL); and on the 21 identically packed pairs it still cost -0.14 supported and -0.10 answers. It gained on two of three CVE rows. Rule established and pinned by a test: an instruction has to beat the rung it displaces. Production wording is plain again; the sentence survives as INSTRUCTION_VARIANTS["yesno_line"] / harness condition compiled+yesno for a retest against a larger answering model. The v4 rubric also stops marking RAG's grounded declines as 0 (rag answers_question 0.49 vs 0.24-0.31 under v3) and now counts absence-over-unchecked as a misread (rag 0.15). Co-Authored-By: Claude Fable 5.1 Signed-off-by: Dan Wolfson --- .../docs/experiments/compiled-vs-rag.md | 62 ++++++++++++++++++- .../resource_explorer/context_compile.py | 42 +++++++------ .../scripts/experiment_compiled_vs_rag.py | 4 +- .../tests/test_context_compile.py | 58 +++++++++-------- ...st_conversation_agent_compiled_evidence.py | 4 +- 5 files changed, 118 insertions(+), 52 deletions(-) diff --git a/packages/resource-explorer/docs/experiments/compiled-vs-rag.md b/packages/resource-explorer/docs/experiments/compiled-vs-rag.md index 8694a323d..0f78617a5 100644 --- a/packages/resource-explorer/docs/experiments/compiled-vs-rag.md +++ b/packages/resource-explorer/docs/experiments/compiled-vs-rag.md @@ -14,7 +14,7 @@ Results are appended to this file's "Runs" section as they land; none yet. | Unit | one (repository, catalog question, condition) | | Questions | the full repo question catalog (52 entries on 2026-09-08), shuffled with a fixed seed so order does not follow catalog position; `--limit N` takes the first N of that order | | Repositories | `egeria_python_git`, `kafka`, `docling` by default: the three with the deepest stored analysis results across different languages and communities | -| Conditions | Named in `CONDITION_SPECS` as (compiled evidence on/off, instructions variant) and chosen per run with `--conditions`; each question is answered under every condition **back-to-back in one process**. `compiled`: `ConversationAgent(compiled_evidence=True)`, production wording. `compiled-plain`: production minus the yes/no sentence (`instructions_variant="no_yesno_line"`) — the other arm of the within-run A/B. `rag`: the same agent with `compiled_evidence=False`, so it must search collections itself. **Nothing else differs**: same tools, prompt shape, model, tier, fallback | +| Conditions | Named in `CONDITION_SPECS` as (compiled evidence on/off, instructions variant) and chosen per run with `--conditions`; each question is answered under every condition **back-to-back in one process**. `compiled`: `ConversationAgent(compiled_evidence=True)`, production wording. `compiled+yesno`: production plus run 8's yes/no sentence (`instructions_variant="yesno_line"`; in run 9 the arms were the other way round, when the sentence was still production and the plain arm was called `compiled-plain`). `rag`: the same agent with `compiled_evidence=False`, so it must search collections itself. **Nothing else differs**: same tools, prompt shape, model, tier, fallback | | Stored state | The run **disables the three repos' Automate schedules for its window and restores them afterwards with their original `next_run`** (`registry.pause_schedules` / `resume_schedules`, `--no-pause-schedules` to opt out). Added after run 8, when the nightly `RepoRefreshSurvey` re-surveyed two repos while the run was answering | | Answering model | whatever `LLM__OLLAMA__MODEL` and `EXPLORER_MODEL_TIER` resolve to; record them with each run (default `llama3.1:8b`, tier `dev`) | | Perspectives | none set, the common case | @@ -83,6 +83,64 @@ Runs judged under different rubrics are never averaged together. ## Runs +### run9-20260912 — the within-run A/B: the yes/no sentence, measured on its own, comes out of production + +First run under the redesigned protocol: three conditions answered back-to-back per question in +one process (`compiled` = production wording, which carried the yes/no sentence since PR #38; +`compiled-plain` = the same without it; `rag`), rubric v4, text and compile id on every row, +and the three repos' Automate schedules paused for the window (egeria's `RepoRefreshSurvey`, +docling's `RepoAnalysisSurvey` and `RepoRefreshSurvey`; kafka has none) and restored afterwards +with their original `next_run`. Window: 2026-09-12 12:32 → 20:25 local. 468 rows, 0 errors, +replayability 156 of 156 in both compiled arms. `data/experiments/compiled_vs_rag_run9/`. + +| metric (v4) | compiled (with sentence) | compiled-plain (without) | rag | +|---|---:|---:|---:| +| answers_question (0–2) | 0.98 | **1.04** | 0.49 | +| supported_claims (mean) | 1.54 | **2.12** | 0.22 | +| unsupported_claims (mean) | 0.41 | 0.43 | 0.77 | +| misread_claims (mean) | 0.06 | 0.04 | 0.15 | +| cites_evidence | 63% | 68% | 5% | +| acknowledges_limits | 29% | 48% | 56% | +| declines | 22% | 32% | 53% | +| median answer length (chars) | 114 | 181 | 93 | +| bare one-word answers | 15 | 10 | — | + +**Paired, same question, same stored state, sentence minus no-sentence:** answers_question −0.06 +(25 up, 38 down, 93 tied); supported_claims **−0.58** (32 up, 53 down, 71 tied); unsupported +−0.02; misread +0.03; cites −4 points; declines −10 points. Split by question shape: on the 72 +yes/no questions supported 0.65 vs 1.00 and unsupported 0.44 vs 0.28; on the 84 others supported +2.31 vs 3.08. The sentence made the 8B answerer terser on every kind of question, not only the +yes/no ones it addressed. + +**Two mechanisms, separated.** The two arms compiled the same evidence with different +instructions — and only 21 of 156 pairs packed identically. The instructions section is +*required*, so the sentence's 202 characters came out of the evidence: in 135 compiles one +section dropped a rung (1,275 evidence sections at FULL with the sentence, 1,396 without). +On the 21 identically packed pairs the sentence still cost supported −0.14, answers_question +−0.10 and 32 characters of median answer length; on the 135 others, supported −0.64. So both +mechanisms are real: **an instruction is paid for in evidence, and this one also primed brevity.** +The general rule this establishes for the compiler: any instruction has to beat the rung it +displaces. + +**What it was for.** On the three CVE rows the sentence did its job — egeria "yes, 1 advisory for +click ==8.3.1: PYSEC-2026-2132" (2) against a bare "Yes" (0); docling states the coverage, with +the contradictory "Yes" prefix (1, one misread) against "Yes, there are outstanding CVEs" (0); +kafka correct in both arms. Two rows gained; the rest of the catalog paid. + +**Decision.** The sentence is removed from production in this same change (PR #38 reverted in +effect; run 8's entry above said "branch not merged" and was overtaken by the merge). It stays +as `INSTRUCTION_VARIANTS["yesno_line"]` and the harness condition `compiled+yesno`, so it can be +retested one day against a larger answering model, where terseness may not be the response. The +bare yes/no on CVE questions is therefore an open answering-side problem again, now with the +measurement that a prompt sentence is the wrong tool for it on this model. + +**Rubric v4 and the control.** The `rag` arm scored 0.49 on answers_question against 0.24–0.31 +under v3 in runs 4–8: v4's decline rule no longer marks a grounded decline as 0 when only an +unmapped section touches the topic, and RAG declines a lot (53%). Its misread rate (0.15) is +now visible where v3 could not count absence-over-unchecked. The compiled-versus-RAG gap is +unchanged in shape: four to ten times the supported claims, half the unsupported, cites 63–68% +against 5%. + ### run8-20260912 — "a yes/no answer must carry its evidence line"; confounded, and negative on the aggregate One intended variable: the instructions (FULL and SUMMARY rungs) now say a yes/no answer must be @@ -145,7 +203,7 @@ on questions the sentence does not apply to; a sentence that says "give the yes the evidence line" plausibly does, by priming answer-then-one-line everywhere. That reading is consistent with the data and not established by it, because of the confound above. -**Decision.** The branch is not merged. Run 8 cannot attribute, and the one clean signal it has — +**Decision (overtaken: PR #38 merged the branch on 2026-09-12 before this entry landed; run 9 below measured the sentence and removed it).** Run 8 cannot attribute, and the one clean signal it has — the CVE rows now carry their evidence — is three rows. The next design is the one the protocol should have used from run 3 on: a **within-run A/B**, two compiled conditions (`compiled` and `compiled+yesno`) answered back-to-back for each question in one process, with the instruction diff --git a/packages/resource-explorer/resource_explorer/context_compile.py b/packages/resource-explorer/resource_explorer/context_compile.py index 26cda2b42..bf6c60a4d 100644 --- a/packages/resource-explorer/resource_explorer/context_compile.py +++ b/packages/resource-explorer/resource_explorer/context_compile.py @@ -55,19 +55,20 @@ "Answer using only the evidence below. Every section states which analysis " "produced it. A section marked 'abridged' shows only the first few entries " "of each list or mapping — its counts and entries are partial, so do not " - "report an abridged list as complete. If the question asks yes or no, give " - "the yes or no and then the evidence line it rests on — the analysis, the " - "value, and any coverage limit or caveat shown beside it; a bare yes or no " - "is not an answer. If the evidence does not answer the question, say so and " - "name what is missing — do not infer from absence." + "report an abridged list as complete. If the evidence does not answer the " + "question, say so and name what is missing — do not infer from absence." ) -#: The yes/no sentence is the ONE variable of experiment run 8 (2026-09-11). -#: Runs 5-7 put an honest headline ("not checked: 0 of 61 declared -#: dependenc(ies) could be queried") and the catalog's own caveat directly -#: under it, and the 8B answerer still replied "No", then "Yes", to "Are -#: there outstanding CVEs?" on docling. Three compilers' worth of evidence -#: did not move a one-word answer, so the instruction now asks for the line -#: the answer rests on. Measured, not assumed: see the experiment doc. +#: A sentence asking yes/no answers to carry their evidence line was added +#: here by PR #38 (run 8) and removed after run 9's within-run A/B +#: (2026-09-12): it cost one evidence section a rung in 135 of 156 compiles +#: (instructions are a required section, so 202 characters of instruction +#: are 202 characters less evidence), and even where packing was identical +#: it made the 8B answerer terser everywhere (supported claims -0.58 paired, +#: median answer 114 vs 180 chars) for a gain on two of three CVE rows. It +#: survives as INSTRUCTION_VARIANTS["yesno_line"] so a later A/B can retest +#: it, e.g. with a larger model. The general lesson: every instruction +#: character is paid for in evidence, so an instruction has to beat the +#: rung it displaces. #: The same instructions at the packer's SUMMARY rung, for budgets too small #: to carry the template. Instructions are required, so without a shorter @@ -75,8 +76,7 @@ #: one section that used to be exempt from the ladder now climbs it too. _INSTRUCTIONS_SHORT = ( "Answer only from the evidence below; name the analysis behind each point. " - "'Abridged' sections show first entries only. A yes/no answer must state " - "the evidence line and its coverage limit. If it does not answer, say " + "'Abridged' sections show first entries only. If it does not answer, say " "which analysis would and whether it has run. Do not infer from absence." ) @@ -97,20 +97,22 @@ #: the catalog moves, so run-over-run comparisons carry three variables. #: The variant changes the instructions candidate's text, so it is part of #: the compile id without any further bookkeeping; the manifest names it. -#: "default" is production, which since PR #38 carries run 8's yes/no -#: sentence; "no_yesno_line" is the same wording without it, so the A/B -#: measures that sentence on its own. +#: "default" is production (plain, since run 9); "yesno_line" adds run 8's +#: sentence, so it can be retested without editing production wording. _YESNO_SENTENCE = ( "If the question asks yes or no, give the yes or no and then the evidence " "line it rests on — the analysis, the value, and any coverage limit or caveat " "shown beside it; a bare yes or no is not an answer. " ) _YESNO_SENTENCE_SHORT = "A yes/no answer must state the evidence line and its coverage limit. " -assert _YESNO_SENTENCE in _INSTRUCTIONS and _YESNO_SENTENCE_SHORT in _INSTRUCTIONS_SHORT INSTRUCTION_VARIANTS: dict[str, tuple[str, str]] = { "default": (_INSTRUCTIONS, _INSTRUCTIONS_SHORT), - "no_yesno_line": (_INSTRUCTIONS.replace(_YESNO_SENTENCE, ""), - _INSTRUCTIONS_SHORT.replace(_YESNO_SENTENCE_SHORT, "")), + "yesno_line": ( + _INSTRUCTIONS.replace("If the evidence does not answer the question", + _YESNO_SENTENCE + "If the evidence does not answer the question"), + _INSTRUCTIONS_SHORT.replace("If it does not answer", + _YESNO_SENTENCE_SHORT + "If it does not answer"), + ), } #: How many evidence sections a compile packs, counted after ranking. Ranking diff --git a/packages/resource-explorer/scripts/experiment_compiled_vs_rag.py b/packages/resource-explorer/scripts/experiment_compiled_vs_rag.py index 68ca0ddf3..ceb14cc47 100644 --- a/packages/resource-explorer/scripts/experiment_compiled_vs_rag.py +++ b/packages/resource-explorer/scripts/experiment_compiled_vs_rag.py @@ -49,8 +49,8 @@ #: that attributes anything here (the scheduler re-surveys repos and the #: catalog moves between runs). CONDITION_SPECS = { - "compiled": (True, "default"), # production wording (carries the yes/no sentence since PR #38) - "compiled-plain": (True, "no_yesno_line"), # the same without that sentence: the A/B's other arm + "compiled": (True, "default"), # production wording + "compiled+yesno": (True, "yesno_line"), # production plus run 8's yes/no sentence (run 9's other arm, then named compiled-plain/default the other way round) "rag": (False, "default"), } CONDITIONS = ("compiled", "rag") # the default pair; --conditions overrides diff --git a/packages/resource-explorer/tests/test_context_compile.py b/packages/resource-explorer/tests/test_context_compile.py index 7f4c23f77..d18ac5414 100644 --- a/packages/resource-explorer/tests/test_context_compile.py +++ b/packages/resource-explorer/tests/test_context_compile.py @@ -1164,51 +1164,57 @@ def test_the_short_rung_carries_a_shorter_caveat(self, monkeypatch): class TestAYesNoAnswerMustCarryItsEvidenceLine: - """Run 8's one variable. Runs 5-7: honest headline, caveat beside it, and - the answer to "Are there outstanding CVEs?" on docling was still a bare - "No" and then a bare "Yes".""" + """Run 8's sentence: added by PR #38, removed after run 9 (see + TestInstructionVariants). What this pins now is that production does + NOT carry it and the variant does.""" - def test_full_and_short_rungs_ask_for_the_line(self): + def test_production_no_longer_carries_the_sentence(self): from resource_explorer.context_compile import ( - _INSTRUCTIONS, _INSTRUCTIONS_SHORT, _INSTRUCTIONS_BARE) - assert "a bare yes or no is not an answer" in _INSTRUCTIONS - assert "coverage limit or caveat" in _INSTRUCTIONS - assert "yes/no answer must state the evidence line" in _INSTRUCTIONS_SHORT - # The bare rung is the budget floor and stays as it was. - assert "yes" not in _INSTRUCTIONS_BARE.lower() - - def test_it_reaches_the_packed_text(self): - c = compile_context(_registry({"repo_conventions": [_finding("a")]}), "x", "q", budget=6000) - assert "a bare yes or no is not an answer" in c.text + _INSTRUCTIONS, _INSTRUCTIONS_SHORT, _INSTRUCTIONS_BARE, INSTRUCTION_VARIANTS) + for text in (_INSTRUCTIONS, _INSTRUCTIONS_SHORT, _INSTRUCTIONS_BARE): + assert "yes" not in text.lower() + assert "a bare yes or no is not an answer" in INSTRUCTION_VARIANTS["yesno_line"][0] + class TestInstructionVariants: """Run 8 (2026-09-12) could not attribute its sentence because the stored state and the catalog moved between runs. Variants let one run carry two wordings over the same state; the variant is in the compile id because it - is in the instructions candidate's text. Production ("default") carries - the yes/no sentence since PR #38; "no_yesno_line" removes only it.""" + is in the instructions candidate's text. Run 9 measured the sentence and + it left production; it stays as a variant.""" - def test_default_is_production_wording_with_the_sentence(self): + def test_default_is_production_wording_without_the_sentence(self): from resource_explorer.context_compile import INSTRUCTION_VARIANTS, _INSTRUCTIONS assert INSTRUCTION_VARIANTS["default"][0] == _INSTRUCTIONS c = compile_context(_registry({"repo_conventions": [_finding("a")]}), "x", "q", budget=6000) - assert "a bare yes or no is not an answer" in c.text + assert "a bare yes or no is not an answer" not in c.text assert c.manifest["instructions_variant"] == "default" - def test_the_plain_variant_drops_only_the_sentence_and_changes_the_id(self): + def test_the_yesno_variant_adds_only_the_sentence_and_changes_the_id(self): from resource_explorer.context_compile import INSTRUCTION_VARIANTS - full, short = INSTRUCTION_VARIANTS["no_yesno_line"] - assert "yes or no" not in full and "yes/no" not in short - assert "Do not infer from absence" in short and "do not infer from absence" in full + full, short = INSTRUCTION_VARIANTS["yesno_line"] + assert "a bare yes or no is not an answer" in full and "yes/no answer must state" in short reg = _registry({"repo_conventions": [_finding("a")]}) a = compile_context(reg, "x", "q", budget=6000) - b = compile_context(reg, "x", "q", budget=6000, instructions_variant="no_yesno_line") - assert "a bare yes or no is not an answer" not in b.text - assert b.manifest["instructions_variant"] == "no_yesno_line" + b = compile_context(reg, "x", "q", budget=6000, instructions_variant="yesno_line") + assert "a bare yes or no is not an answer" in b.text + assert b.manifest["instructions_variant"] == "yesno_line" assert a.compile_id != b.compile_id strip = lambda c: c.text[c.text.index("## "):] - assert strip(a) == strip(b) # same evidence; only the instructions differ + assert strip(a) == strip(b) # same evidence at this budget; only the instructions differ + + def test_instructions_are_paid_for_in_evidence(self): + """Run 9: 202 characters of instruction cost one section a rung in + 135 of 156 compiles. Pinned so the next instruction is weighed + against the rung it displaces rather than assumed free.""" + big = {f"a{i}": [_finding("c", summary="w" * 700)] for i in range(8)} + reg = _registry(big) + a = compile_context(reg, "x", "q", budget=3200) + b = compile_context(reg, "x", "q", budget=3200, instructions_variant="yesno_line") + full = lambda c: sum(p["rung"] == "FULL" for p in c.manifest["packed"] if p["role"] == "evidence") + assert full(a) >= full(b) + assert a.manifest["used"] <= 3200 and b.manifest["used"] <= 3200 def test_an_unknown_variant_is_an_error_not_a_default(self): with pytest.raises(ValueError): diff --git a/packages/resource-explorer/tests/test_conversation_agent_compiled_evidence.py b/packages/resource-explorer/tests/test_conversation_agent_compiled_evidence.py index f9ab59e1e..59430768a 100644 --- a/packages/resource-explorer/tests/test_conversation_agent_compiled_evidence.py +++ b/packages/resource-explorer/tests/test_conversation_agent_compiled_evidence.py @@ -105,7 +105,7 @@ def test_the_agent_hands_its_variant_to_the_compiler(self, monkeypatch): def fake(registry, slug, question, **kw): seen.update(kw); raise RuntimeError("stop here") monkeypatch.setattr(cc, "compile_context", fake) - agent = ConversationAgent(resource_slug="x", instructions_variant="no_yesno_line") + agent = ConversationAgent(resource_slug="x", instructions_variant="yesno_line") assert agent._compiled_evidence("q", "x", []) == [] # fail-soft, as before - assert seen.get("instructions_variant") == "no_yesno_line" + assert seen.get("instructions_variant") == "yesno_line" assert ConversationAgent(resource_slug="x").instructions_variant == "default" From 77fd5b8c7307539ab09c2a34985154fbbb075471 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sat, 12 Sep 2026 15:28:27 -0500 Subject: [PATCH 27/37] =?UTF-8?q?RE:=20dependency=5Fsupport=20=E2=80=94=20?= =?UTF-8?q?put=20the=20question's=20answering=20in=20the=20CSV=20it=20is?= =?UTF-8?q?=20generated=20from?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit question_catalog.yaml is generated from docs/dr-egeria/resource_questions.csv, and its own header says so - "edit that CSV, then regenerate this file, don't hand-edit it directly". The previous commit hand-edited it. The change was right and the artifact was wrong: the next regeneration would have erased it. Caught by the /next session in review, not by me and not by any test. Fixed by the generator's own convention, on top of #45's stage change rather than instead of it. The row's Answering Analysis cell now reads "MIXED: dependency_support (...) + human-supplied corroboration via the Enrichment Context form", which the generator parses to kind: mixed, analysis_ids: [dependency_support]; its Catalog History gains a line saying when and why. Regenerated: the only lines that differ from #45's regeneration are this one question's note and history, which is the proof the generator is deterministic and the rest of the file was already in sync. Three guards fired on the regeneration and each was right. check_registry.yaml must classify every analysis, and dependency_support belongs in instance_keyed_not_checks - one finding per matched technology, the same shape as cve_scan's one-per-package. And two hard-coded counts in the scouting- questions route test moved by exactly one: the question now carries an analysis id, so `measured` is 17 not 16, and it already carried history from its stage change, so `with_history` is 16 not 15. Both updated with the reason written beside them rather than adjusted to pass. The suite has no guard that question_catalog.yaml matches its CSV - the survey definitions have one, the question catalog does not, which is how the hand-edit passed 144 tests. The /next session is adding that guard in its next PR; noted on #46 so it is not built twice. Merges origin/main (7ecb1ac) to sit on top of #45. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- .../docs/dr-egeria/resource_questions.csv | 2 +- .../configdata/check_registry.yaml | 5 +++++ .../configdata/question_catalog.yaml | 19 +++++++++---------- .../tests/test_scouting_questions_route.py | 13 +++++++++---- 4 files changed, 24 insertions(+), 15 deletions(-) diff --git a/packages/resource-explorer/docs/dr-egeria/resource_questions.csv b/packages/resource-explorer/docs/dr-egeria/resource_questions.csv index 4b59bfe87..856b23fc6 100644 --- a/packages/resource-explorer/docs/dr-egeria/resource_questions.csv +++ b/packages/resource-explorer/docs/dr-egeria/resource_questions.csv @@ -11,7 +11,7 @@ Does it replace or extend something we already have?,Discovery,Duplication can b What are similar repos/projects? How does this differ?,Analysis,Helping me choose.,"Needs similarity search over content (RAG/embeddings) — real gap, not built.",GAP: similarity search over pgvector embeddings (not built) - Agent over RAG ,Agent-Based Analysis,,,,,,,,X,,X,,X,Select, Are there any restrictions for use?,Analysis,Is this appropriate?,License field is cheaply available from the GitHub API — deeper copyleft-risk categorization is Assessment-tier (see the license-specifics row below).,"N/A — direct field (GitHub license field) - Also Agent over RAG? Direct field (GitHub license field) for a standard license (e.g. Apache 2.0) — the answer for those is itself usually just ""citation/attribution required"". A non-standard license needs the actual license text interpreted, which is where Agent-Based Analysis comes in instead of a direct-field read.",Direct Field (standard license) + Agent-Based Analysis (non-standard license interpretation),,,,,,,,,,X,X,X,Select;Certify, What dependencies does this require?,Analysis,Influences deployment cost and exposes additional transitive risks.,"The declared dependency list, per ecosystem, from the manifests present.",dependency_analysis ,Code Analysis,,,,,X,,,,X,X,,X,Select;Assess;Deploy,Answered by DependencySurveyor (Analysis tier). -Do we already support these dependencies?,Analysis/Enrichment,Reduces risk.,"Needs the org's own tech inventory — human-supplied cross-reference, not resource-derivable.","N/A — human-supplied via Enrichment Context form - may also be a new Agent over RAG content. Cross-reference against Egeria's catalog of already-supported dependencies, validated/completed by human input — likely needs a per-dependency answer, not one blanket answer for the whole question.",Egeria Queries + Human-Supplied,,,,,,,,,,X,X,X,Select;Deploy,"2026-09-12: stage widened to Analysis/Enrichment — the answer is human-supplied via Enrichment (says so in Answering Analysis), but exact phase matching kept the row off the Enrichment pane, under a heading that promised the catalog's questions for a person." +Do we already support these dependencies?,Analysis/Enrichment,Reduces risk.,"Needs the org's own tech inventory — human-supplied cross-reference, not resource-derivable.","MIXED: dependency_support (machine starting point — each dependency name matched against a curated technology mapping in configdata/dependency_support.yaml, linked to Egeria's technology types; reports what each dependency INDICATES plus a ranked list of names nobody has classified yet) + human-supplied corroboration via the Enrichment Context form — whether a technology is supported remains a human judgement, and likely needs a per-dependency answer rather than one blanket answer for the whole question. May also become an Agent over RAG content.",Egeria Queries + Human-Supplied,,,,,,,,,,X,X,X,Select;Deploy,"2026-09-12: stage widened to Analysis/Enrichment — the answer is human-supplied via Enrichment (says so in Answering Analysis), but exact phase matching kept the row off the Enrichment pane, under a heading that promised the catalog's questions for a person. | 2026-09-12: answering became MIXED — dependency_support added as the machine starting point (project owner: 'A now, shaped to seed B'); the human corroboration stays." What deployment styles does this support?,Analysis,Need to understand if we can deploy it?,"Covers whether a Dockerfile, compose file or Helm chart is present. What the deployment instructions actually say is not read.",MIXED: repo_conventions (deployment_docker presence signal); GAP: RAG content read for deployment instructions [checks: repo_conventions:deployment_docker],Code Analysis + RAG Queries,,,,,,,,,,X,X,X,Select;Deploy,Presence became a cheap Discovery-tier signal through repo_conventions' deployment_docker check; a content read of Dockerfiles and deployment docs remains a RAG-tier gap. Do we know what the cost to run it is?,Analysis/Enrichment,Cost is part of the decision process.,Human-supplied/derived — not resource-derivable.,N/A — human-supplied via Enrichment Context form = may also be Agent over RAG content.,Human-Supplied,X,,,,,,,,,,,X,Select;Deploy,"2026-09-12: stage widened to Analysis/Enrichment — the answer is human-supplied via Enrichment (says so in Answering Analysis), but exact phase matching kept the row off the Enrichment pane, under a heading that promised the catalog's questions for a person." Is there a validation / deployment test for it?,Analysis,How do we know if it is working properly once deployed?,Covers whether CI runs tests and whether deployment evidence exists; a real deployment validation test is not detected.,"MIXED: ci_quality (CI actually runs tests) + repo_conventions (deployment_docker evidence); GAP: real deployment-validation test - May be RAG queries? ci_quality/repo_conventions give structured signals; an Agent examining the repo's actual file/directory structure (test dirs, validation scripts) can find hints beyond those fixed checks. [checks: ci_quality:ci_runs_tests, repo_conventions:deployment_docker]",Code Analysis + Agent-Based Analysis,,,,,,,,,,X,,X,Assess;Deploy,ci_quality replaced a CI-config-presence check with one that reads whether workflows run tests; repo_conventions' deployment_docker added the deployment-evidence signal. diff --git a/packages/resource-explorer/resource_explorer/configdata/check_registry.yaml b/packages/resource-explorer/resource_explorer/configdata/check_registry.yaml index 2fe410a95..060661f56 100644 --- a/packages/resource-explorer/resource_explorer/configdata/check_registry.yaml +++ b/packages/resource-explorer/resource_explorer/configdata/check_registry.yaml @@ -291,5 +291,10 @@ instance_keyed_not_checks: # dependency set happens to contain — not a fixed set of checks that # could be enumerated here. - cve_scan + # One finding per matched TECHNOLOGY (label = the technology's name), so + # the keys are whatever the curated mapping in dependency_support.yaml + # happens to match for this repo — plus one `coverage` row. Same shape as + # cve_scan: the instance set is the dependency list's, not a fixed list. + - dependency_support - architecture_recovery - sub_resource_survey diff --git a/packages/resource-explorer/resource_explorer/configdata/question_catalog.yaml b/packages/resource-explorer/resource_explorer/configdata/question_catalog.yaml index 04486c2be..3145fcfa8 100644 --- a/packages/resource-explorer/resource_explorer/configdata/question_catalog.yaml +++ b/packages/resource-explorer/resource_explorer/configdata/question_catalog.yaml @@ -314,20 +314,19 @@ repo_questions: analysis_ids: - dependency_support checks: [] - note: Machine-informed, human-confirmed. dependency_support cross-references the dependency - list against a curated technology mapping (configdata/dependency_support.yaml) linked to - Egeria's technology types, and reports what each dependency INDICATES plus a ranked list of - names nobody has classified yet. Whether a technology is supported remains a human judgement - — the analysis is the starting point the project owner asked for (2026-09-11), not the answer. - Was `human` until 2026-09-12; the earlier note read — - N/A — human-supplied via Enrichment Context form - may also be a new Agent over RAG content. - Cross-reference against Egeria's catalog of already-supported dependencies, validated/completed - by human input — likely needs a per-dependency answer, not one blanket answer for the whole question. + note: 'MIXED: dependency_support (machine starting point — each dependency name matched against a + curated technology mapping in configdata/dependency_support.yaml, linked to Egeria''s technology + types; reports what each dependency INDICATES plus a ranked list of names nobody has classified + yet) + human-supplied corroboration via the Enrichment Context form — whether a technology is supported + remains a human judgement, and likely needs a per-dependency answer rather than one blanket answer + for the whole question. May also become an Agent over RAG content.' answering_mechanism: Egeria Queries + Human-Supplied rationale: Needs the org's own tech inventory — human-supplied cross-reference, not resource-derivable. catalog_history: '2026-09-12: stage widened to Analysis/Enrichment — the answer is human-supplied via Enrichment (says so in Answering Analysis), but exact phase matching kept the row off the Enrichment - pane, under a heading that promised the catalog''s questions for a person.' + pane, under a heading that promised the catalog''s questions for a person. | 2026-09-12: answering + became MIXED — dependency_support added as the machine starting point (project owner: ''A now, shaped + to seed B''); the human corroboration stays.' - question: What deployment styles does this support? stage: Analysis perspectives: diff --git a/packages/resource-explorer/tests/test_scouting_questions_route.py b/packages/resource-explorer/tests/test_scouting_questions_route.py index e2b5b2504..83e4045e9 100644 --- a/packages/resource-explorer/tests/test_scouting_questions_route.py +++ b/packages/resource-explorer/tests/test_scouting_questions_route.py @@ -207,7 +207,10 @@ def test_no_analysis_stage_limit_reads_as_a_changelog(self, client): qs = client.get("/api/projects/myproj/scouting-questions", params={"phase": "analysis"}).json()["questions"] measured = [q for q in qs if q["analysis_ids"]] - assert len(measured) == 16 + # 17 since 2026-09-12: "Do we already support these dependencies?" + # went from `human` to `mixed` when dependency_support gave it a + # machine starting point, so it now carries an analysis id. + assert len(measured) == 17 tells = ("shipped 2026", "was marked GAP", "landed 2026", "could not parse", "Closed 2026", "before any question referenced") leaked = [q["question"] for q in measured @@ -219,6 +222,8 @@ def test_history_is_carried_and_only_where_authored(self, client): params={"phase": "analysis"}).json()["questions"] assert all(isinstance(q.get("catalog_history"), str) for q in qs) with_history = [q for q in qs if q["analysis_ids"] and q["catalog_history"]] - # 15 of the 16: the security-infrastructure question is a person's to - # answer and has no build history to record. - assert len(with_history) == 15 + # 16 of the 17: the security-infrastructure question is a person's to + # answer and has no build history to record. (Was 15 of 16 until + # 2026-09-12, when the dependencies question joined `measured` already + # carrying history from its Analysis/Enrichment stage change.) + assert len(with_history) == 16 From a9a878c90e52f3dc99adbf5c4dd82fd58e01516d Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sat, 12 Sep 2026 15:38:04 -0500 Subject: [PATCH 28/37] RE tests: the promotion route's suggestion now carries the list's name too Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- packages/resource-explorer/tests/test_promotion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/resource-explorer/tests/test_promotion.py b/packages/resource-explorer/tests/test_promotion.py index 2eb3c7cb7..4b0b5a7b1 100644 --- a/packages/resource-explorer/tests/test_promotion.py +++ b/packages/resource-explorer/tests/test_promotion.py @@ -73,7 +73,7 @@ def test_journal_entry_carries_the_line_and_routes_a_suggestion(self, client, re assert r.status_code == 200, r.text e = Journal(registry).entries("repo", "p")[0] assert e["author"] == "peterprofile" and "from cve_scan, run 2026-09-03" in e["body"] - assert r.json()["work_lists"] == [{"target": "Security", "work_list": "suggested-to-security"}] + assert r.json()["work_lists"] == [{"target": "Security", "work_list": "suggested-to-security", "name": "Suggested to Security"}] def test_rfa_is_raised_with_the_line_as_detail(self, client, registry): r = client.post("/api/projects/p/members/cve_scan/promote", json={"action": "rfa", **SEL}) From a1c03d1ef698c43d7cc8959cca24c35430c1583a Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sat, 12 Sep 2026 15:47:18 -0500 Subject: [PATCH 29/37] =?UTF-8?q?RE:=20dependency=5Fsupport=20=E2=80=94=20?= =?UTF-8?q?three=20guards=20CI=20caught=20that=20a=20targeted=20local=20ru?= =?UTF-8?q?n=20did=20not?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full suite on the branch failed four tests I had not run locally. Three are mine and fixed here; the fourth is in the compiler and is handed to the session whose in-flight branch owns that file. The no-dependencies branch and the normal branch both emitted an annotation named `coverage` with no item key, so both would publish one qualifiedName if they ever ran in the same survey (test_annotation_check_names). They cannot both run, but the honest fix is not an exclusivity exemption: the early return says "nothing to assess", which is a different statement from "here is how much was covered", and it is now named `nothing_to_assess`. The persisted finding row is unchanged - check_name `coverage`, label `no-dependencies` - which is what the results reader keys on. repo_dependency_support is added to the exhaustive step-key set in test_repo_survey_definition_adapter, which is exhaustive on purpose so a new step is a decision, and dependency_support joins the Dependencies dashboard beside dependency_analysis - it reads the same project_dependencies rows, so the two are one question asked twice: what is here, and what does it mean. The fourth failure, test_context_compile's reaches_the_answer_through_compile_ context_too, is the compiler's, and I have not touched it. Verified locally: context_compile._has_content returns True for a `{"_status": {...}}` envelope while facts._has_content returns False for the same dict. The compiler therefore packs a never-run analysis as a section headed "_status: state=never_run" rather than listing it as a gap. Every reader that follows the _status convention is affected; this analysis tipped the count so that, under the 12-section cap, a never-run section displaced documentation_coverage. Diagnosis and a proposed one-line fix are with the owning session. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- .../surveyors/repo_survey_definition_adapter.py | 8 ++++++-- .../surveyors/sub_surveyors/dependency_support.py | 8 +++++++- .../resource-explorer/tests/test_dependency_support.py | 7 ++++++- .../tests/test_repo_survey_definition_adapter.py | 3 +++ 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/resource-explorer/resource_explorer/surveyors/repo_survey_definition_adapter.py b/packages/resource-explorer/resource_explorer/surveyors/repo_survey_definition_adapter.py index b2a4a0aa6..7fd925d3c 100644 --- a/packages/resource-explorer/resource_explorer/surveyors/repo_survey_definition_adapter.py +++ b/packages/resource-explorer/resource_explorer/surveyors/repo_survey_definition_adapter.py @@ -4471,8 +4471,12 @@ class SurveyResultDashboard: ), "dependencies": SurveyResultDashboard( "dependencies", "Dependencies", - "Package dependencies per ecosystem.", - ["dependency_analysis"], + "Package dependencies per ecosystem, and which curated technologies they indicate — " + "a starting point for 'do we already support these?', not an answer.", + # dependency_support added 2026-09-12: it reads the same + # project_dependencies rows dependency_analysis reports on, so the two + # are one question asked twice — what is here, and what does it mean. + ["dependency_analysis", "dependency_support"], ), # interface_surface added 2026-08-31, same Backlog entry — "what can be # talked to, and whether the contract is written down" is the same diff --git a/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/dependency_support.py b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/dependency_support.py index bfe5c6524..4717d560b 100644 --- a/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/dependency_support.py +++ b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/dependency_support.py @@ -54,7 +54,13 @@ def run(self) -> list[Annotation]: results.append(ClassificationAnnotation( summary="No dependency rows held for this repository — nothing to assess " "(manifest parsing may not have run)", - analysis_step=STEP, check_name="coverage", + # Not "coverage": that name belongs to the normal path's + # annotation below, and two annotations sharing a check_name + # with no item key publish the same qualifiedName if both + # ever run in one survey (test_annotation_check_names). This + # branch says "nothing to assess", which is a different + # statement from "here is how much was covered". + analysis_step=STEP, check_name="nothing_to_assess", candidate_classifications=[], confidence=0, json_properties={"total_dependencies": 0, **outcome.as_row()}, )) diff --git a/packages/resource-explorer/tests/test_dependency_support.py b/packages/resource-explorer/tests/test_dependency_support.py index 75b6108c7..19150e111 100644 --- a/packages/resource-explorer/tests/test_dependency_support.py +++ b/packages/resource-explorer/tests/test_dependency_support.py @@ -228,7 +228,12 @@ def _run(self, reg, slug, monkeypatch, egeria=("unreachable", None)): def test_no_dependency_rows_is_unverified_not_zero_technologies(self, reg, slug, monkeypatch): anns = self._run(reg, slug, monkeypatch) - assert len(anns) == 1 and anns[0].check_name == "coverage" + # The ANNOTATION is `nothing_to_assess` — a different statement from the + # normal path's `coverage`, and two annotations sharing a check_name + # with no item key would publish one qualifiedName. The persisted + # FINDING row keeps check_name `coverage` / label `no-dependencies`, + # which is what the results reader keys on. + assert len(anns) == 1 and anns[0].check_name == "nothing_to_assess" assert anns[0].json_properties.get("outcome") == "unverified" rows = reg.query_findings(slug, "dependency_support") assert rows and rows[0]["label"] == "no-dependencies" diff --git a/packages/resource-explorer/tests/test_repo_survey_definition_adapter.py b/packages/resource-explorer/tests/test_repo_survey_definition_adapter.py index 395c0390c..8a7cfe6d6 100644 --- a/packages/resource-explorer/tests/test_repo_survey_definition_adapter.py +++ b/packages/resource-explorer/tests/test_repo_survey_definition_adapter.py @@ -50,6 +50,9 @@ def test_all_step_keys_are_registered(): "repo_health", "repo_homepage", "repo_website_ingestion", "repo_dependency", "repo_documentation", "repo_security", "repo_api_structure", "repo_data_profiling", "repo_file_classification", "repo_sub_resource_survey", "repo_license_classification", + # 2026-09-12: dependency_support — which curated technologies the + # dependency list indicates, and whether Egeria holds a type for each. + "repo_dependency_support", "repo_security_features", "repo_ci_quality", "repo_maturity", "repo_conventions", "repo_symbol_extraction", "repo_rag_ingestion", "repo_foss_scorecard", "repo_cve_scan", "repo_community_support", "repo_interface_surface", From 8475ac8c1e29d2a62134441a7e02b07338739e65 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sat, 12 Sep 2026 15:49:56 -0500 Subject: [PATCH 30/37] RE compiler: a _status-only envelope is a gap, not a packed section context_compile._has_content counted {"_status": {"state": "never_run"}} as content, so a never-run analysis whose reader follows the result_status.py envelope convention was packed as a section headed "_status: state=never_run" instead of judged as a gap. facts._has_content has exempted the envelope keys (_status, surveyed_at, detail, scoped_to, run_outcomes) since it was written; the compiler's copy did not, and its docstring's "the readers do not carry that distinction" stopped being true with architecture_diagram, architecture_recovery and dependency_support. Surfaced by #46's CI (diagnosed and reproduced by dwolfson-4c): the new dependency_support reader returned the envelope against an empty registry, took a slot under the 12-section cap, displaced documentation_coverage, and test_reaches_the_answer_through_compile_context_too lost "readme". Fix: the same envelope key set, skipped at the top level of the results dict. Tests: the truth table gains three envelope rows; a test pins that the compiler's and the fact layer's checks agree; a never-run reader now lands in gaps and never in the text. 131 tests. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Dan Wolfson --- .../resource_explorer/context_compile.py | 22 +++++++++++++- .../tests/test_context_compile.py | 29 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/resource-explorer/resource_explorer/context_compile.py b/packages/resource-explorer/resource_explorer/context_compile.py index bf6c60a4d..fbb8c8c55 100644 --- a/packages/resource-explorer/resource_explorer/context_compile.py +++ b/packages/resource-explorer/resource_explorer/context_compile.py @@ -306,6 +306,12 @@ def _findings_to_rungs(findings: list[dict], analysis_id: str) -> dict[Rung, str } +#: Keys that describe a run rather than carry a result. Kept identical to the +#: set inside facts._has_content (tests/test_context_compile.py pins that the +#: two functions agree on an envelope-only dict). +_ENVELOPE_KEYS = frozenset({"_status", "surveyed_at", "detail", "scoped_to", "run_outcomes"}) + + def _has_content(results) -> bool: """Whether a results dict says anything, as opposed to merely existing. @@ -323,7 +329,21 @@ def _has_content(results) -> bool: """ if not isinstance(results, dict): return bool(results) - for value in results.values(): + for key, value in results.items(): + # Envelope keys describe the RUN, not a result. `{"_status": {"state": + # "never_run", ...}}` is a reader saying "nothing yet" in the + # result_status.py convention, and until 2026-09-13 this function + # counted that dict as content -- so a never-run analysis whose reader + # follows the convention was PACKED as a section headed "_status: + # state=never_run" instead of judged as a gap. Found by #46's CI: a + # new envelope-returning reader (dependency_support) displaced + # documentation_coverage under the 12-section cap and a test lost the + # word "readme". Same key set as facts._has_content, which has + # exempted these since it was written; the docstring above, written + # when "the readers do not carry that distinction", is now wrong for + # architecture_diagram, architecture_recovery and dependency_support. + if key in _ENVELOPE_KEYS: + continue # Each type is decided exactly once. An earlier version put the # numeric test in an `elif ... and value` and then had a catch-all # `elif value is not None`, so a zero failed the numeric branch and was diff --git a/packages/resource-explorer/tests/test_context_compile.py b/packages/resource-explorer/tests/test_context_compile.py index d18ac5414..cc841200f 100644 --- a/packages/resource-explorer/tests/test_context_compile.py +++ b/packages/resource-explorer/tests/test_context_compile.py @@ -428,6 +428,9 @@ class TestHasContent: @pytest.mark.parametrize("value,expected", [ ({"by_ecosystem": {}, "total": 0}, False), ({"findings": []}, False), + ({"_status": {"state": "never_run", "outcome": "not_run"}}, False), # envelope only + ({"_status": {"state": "measured"}, "surveyed_at": "2026-09-13", "detail": {"x": 1}}, False), + ({"_status": {"state": "measured"}, "total": 3}, True), # envelope + a result ({}, False), ({"x": None}, False), ({"s": ""}, False), @@ -1219,3 +1222,29 @@ def test_instructions_are_paid_for_in_evidence(self): def test_an_unknown_variant_is_an_error_not_a_default(self): with pytest.raises(ValueError): compile_context(_registry({}), "x", "q", budget=6000, instructions_variant="nope") + + +class TestAStatusOnlyEnvelopeIsAGapNotASection: + """#46's CI (2026-09-13): a never-run reader returning the result_status + envelope was packed as a section headed "_status: state=never_run", + displaced documentation_coverage under the section cap, and a test lost + "readme". Diagnosed by dwolfson-4c; the compiler's _has_content lacked the + envelope exemption facts._has_content has always had.""" + + def test_the_two_content_checks_agree_on_an_envelope(self): + from resource_explorer.context_compile import _has_content as compiler_has + from resource_explorer.facts import _has_content as facts_has + env = {"_status": {"state": "never_run", "outcome": "not_run", "reason": "no rows"}} + assert compiler_has(env) is False and facts_has(env) is False + real = {"_status": {"state": "measured"}, "total": 62} + assert compiler_has(real) is True and facts_has(real) is True + + def test_a_never_run_reader_lands_in_gaps_not_packed(self, monkeypatch): + import resource_explorer.surveyors.repo_survey_definition_adapter as adapter + from resource_explorer import context_compile as cc + monkeypatch.setitem(adapter.REPO_ANALYSIS_RESULTS_MAP, "dependency_analysis", + (lambda reg, slug: {"_status": {"state": "never_run", "outcome": "not_run"}}, None)) + c = cc.compile_context(_registry({}), "x", "What dependencies does this require?", budget=6000) + assert "dependency_analysis" not in {p["key"] for p in c.manifest["packed"]} + assert "dependency_analysis" in {g["key"] for g in c.manifest["gaps"]} + assert "state=never_run" not in c.text From 4f593895a2bae29cfbb12b3b99248cbb8a6b1251 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sat, 12 Sep 2026 15:58:14 -0500 Subject: [PATCH 31/37] RE compiler: the section cap counts sections with evidence; a gap spends no slot Second finding from #46's CI (dwolfson-4c, 2026-09-13): the cap was applied to the RANKING before any analysis was resolved, so a gap had already spent a slot. Against a sparse registry eleven of twelve slots went to gaps and the one analysis with findings, ranked 13th, was deferred with "below the section cap". The cap=12 sweep never saw it: on the experiment repos every cited analysis had run. The walk now continues down the ranking until `cap` analyses have a candidate; gaps found on the way are still reported (they are information) and cost nothing; what lies below the point where the cap is reached is deferred unresolved, in rank order. Tests: the fake registry is now a _NullMock whose unconfigured methods return None instead of nested mocks - readers were building dicts of "" values that _has_content counted as content, and one existing test (packing order vs repository_health) passed only because of that garbage; it now gets a real finding. Three new tests: the only analysis with findings packs however low it ranks; the cap still bounds sections with evidence; no MagicMock text reaches a compile. 134 pass. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Dan Wolfson --- .../resource_explorer/context_compile.py | 35 ++++++---- .../tests/test_context_compile.py | 65 ++++++++++++++++++- 2 files changed, 87 insertions(+), 13 deletions(-) diff --git a/packages/resource-explorer/resource_explorer/context_compile.py b/packages/resource-explorer/resource_explorer/context_compile.py index fbb8c8c55..beb6b6f01 100644 --- a/packages/resource-explorer/resource_explorer/context_compile.py +++ b/packages/resource-explorer/resource_explorer/context_compile.py @@ -115,7 +115,8 @@ ), } -#: How many evidence sections a compile packs, counted after ranking. Ranking +#: How many evidence sections a compile packs, counted after ranking over the +#: analyses that HAVE a candidate -- a gap does not spend a slot. Ranking #: still orders and never excludes at the DERIVATION level — every catalog #: question that reaches an analysis stays in `derivation`, and the sections #: past the cap are reported in the manifest as `deferred`, with their rank @@ -938,17 +939,27 @@ def compile_context( candidates: dict[str, Candidate] = {} ranked = sorted(weights.items(), key=lambda kv: (-kv[1], kv[0])) cap = MAX_EVIDENCE_SECTIONS if max_sections is None else max_sections - deferred = [ - {"key": k, "weight": round(w, 3), "rank": i, - "reason": f"below the section cap of {cap}"} - for i, (k, w) in enumerate(ranked) if cap > 0 and i >= cap - ] - if cap > 0: - ranked = ranked[:cap] + deferred: list[dict] = [] + # The cap counts sections WITH EVIDENCE, and a gap costs nothing. The + # first version sliced the ranking to `cap` entries before resolving any, + # so an analysis that turned out to be a gap had already spent a slot: + # on a sparsely surveyed repo eleven of twelve slots went to gaps and the + # one analysis with real findings sat at rank 13, "deferred". Found by + # #46's CI against an empty registry, diagnosed by dwolfson-4c + # (2026-09-13). The cap=12 sweep that chose the value was on repos where + # every cited analysis had run, so it never showed there. Now the walk + # continues down the ranking until `cap` analyses have a candidate; + # gaps found on the way are still reported as gaps (they are information + # -- "should exist, has nothing behind it"); what remains below the + # point where the cap is reached is deferred unresolved. + packed_count = 0 # Failures the compile survived but the caller must be able to see. extra_notes: list[str] = [] - for analysis_id, weight in ranked: - sections.append(Section(analysis_id, role="evidence", weight=weight)) + for rank, (analysis_id, weight) in enumerate(ranked): + if cap > 0 and packed_count >= cap: + deferred.append({"key": analysis_id, "weight": round(weight, 3), "rank": rank, + "reason": f"below the section cap of {cap}"}) + continue findings = registry.query_findings(slug, analysis_id) rungs = _findings_to_rungs(findings, analysis_id) provenance = _provenance(findings, analysis_id) @@ -1019,14 +1030,16 @@ def compile_context( if rungs and analysis_id in caveat_ids: rungs = _with_caveat(rungs, coverage["caveat"]) + sections.append(Section(analysis_id, role="evidence", weight=weight)) if rungs: candidates[analysis_id] = Candidate( analysis_id, rungs, provenance=provenance, pointer=_pointer_for(analysis_id, slug, provenance), ) + packed_count += 1 # No rungs => no candidate => the packer records a gap. Deliberately not # skipped here: a section the derivation says should exist, with nothing - # behind it, is information. + # behind it, is information -- and it does not count against the cap. # The instructions candidate is built last because its text depends on # where the caveat went. The coverage line (and the caveat, when it has no diff --git a/packages/resource-explorer/tests/test_context_compile.py b/packages/resource-explorer/tests/test_context_compile.py index cc841200f..2be72a055 100644 --- a/packages/resource-explorer/tests/test_context_compile.py +++ b/packages/resource-explorer/tests/test_context_compile.py @@ -12,8 +12,25 @@ from resource_explorer.context_compile import compile_context +class _NullMock(MagicMock): + """A registry double whose unconfigured methods return None, not another + mock. The results readers call a dozen registry methods; left as bare + MagicMocks those returned mocks, readers built dicts of "" values, _has_content counted them as + content, and compiles packed sections of mock garbage -- one test + depended on that without knowing (found by dwolfson-4c reproducing + #46's CI failure, 2026-09-13). A reader handed None either copes or + raises; the compiler treats both as "nothing stored", which is the + truth for a registry that holds only what the test put in it.""" + + def _get_child_mock(self, **kw): + child = _NullMock(**kw) + child.return_value = None + return child + + def _registry(findings_by_kind): - r = MagicMock() + r = _NullMock() r.query_findings.side_effect = lambda slug, kind, *a, **k: findings_by_kind.get(kind, []) return r @@ -92,7 +109,10 @@ def test_weight_reaches_the_real_compile(self, monkeypatch): monkeypatch.setitem(adapter.REPO_ANALYSIS_RESULTS_MAP, "documentation_coverage", (lambda reg, slug: reader_output, None)) - c = cc.compile_context(_registry({}), "egeria", + # repository_health needs a real finding to pack: until 2026-09-13 it + # packed anyway, from the fake registry's mock values (see _NullMock). + c = cc.compile_context(_registry({"repository_health": [_finding("overall", "85", "score 85/100")]}), + "egeria", "Show me all the documentation survey results for egeria", budget=1200) packed_order = [p["key"] for p in c.manifest["packed"] if p["key"] != "instructions"] @@ -1248,3 +1268,44 @@ def test_a_never_run_reader_lands_in_gaps_not_packed(self, monkeypatch): assert "dependency_analysis" not in {p["key"] for p in c.manifest["packed"]} assert "dependency_analysis" in {g["key"] for g in c.manifest["gaps"]} assert "state=never_run" not in c.text + + +class TestGapsDoNotSpendCapSlots: + """#46's CI, second finding (dwolfson-4c, 2026-09-13): against an empty + registry eleven of the twelve cap slots went to gaps and the one analysis + with findings, ranked 13th, was deferred. The cap must count sections + with evidence.""" + + def test_the_only_analysis_with_findings_is_packed_however_low_it_ranks(self): + reg = _registry({"documentation_coverage": [_finding("readme", "present", "README.md found")]}) + c = compile_context(reg, "x", "is this ready to adopt?", budget=6000) + packed = [p["key"] for p in c.manifest["packed"] if p["role"] == "evidence"] + # architecture_doc_lens and architecture_summary report content even + # from an empty registry (their readers synthesise defaults -- a + # reader-side question, not the compiler's), so the assertion is + # membership, not equality. + assert "documentation_coverage" in packed + assert "readme" in c.text + assert not any(d["key"] == "documentation_coverage" for d in c.manifest["deferred"]) + # Gaps above it in the ranking are still reported; they cost nothing. + assert len(c.manifest["gaps"]) >= 12 + + def test_the_cap_still_bounds_sections_with_evidence(self): + from resource_explorer.context_compile import MAX_EVIDENCE_SECTIONS + from resource_explorer.surveyors.question_catalog_reader import get_questions + ids = set() + for e in get_questions("repo"): + ids.update((e.get("derivation") or {}).get("analysis_ids") or []) + reg = _registry({i: [_finding("c", summary="w" * 400)] for i in ids}) + c = compile_context(reg, "x", "how well documented is it?", budget=6000) + evidence = [p for p in c.manifest["packed"] if p["role"] == "evidence"] + c.manifest["dropped"] + assert len(evidence) <= MAX_EVIDENCE_SECTIONS + assert c.manifest["deferred"] + # Deferred entries were never resolved: they sit below the point + # where the cap was reached, in rank order. + ranks = [d["rank"] for d in c.manifest["deferred"]] + assert ranks == sorted(ranks) + + def test_a_fake_registry_does_not_leak_mocks_into_sections(self): + c = compile_context(_registry({}), "x", "is this ready to adopt?", budget=6000) + assert "MagicMock" not in c.text From 3244450d110f84f6084e4ad995ebdd152a366097 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sat, 12 Sep 2026 16:34:33 -0500 Subject: [PATCH 32/37] RE Curate: the plan, the record, and the commit -- server half Repo Handoff item 6, the Curate wireframe. curate_plan.build_plan reads only local state -- facts, findings, enrichment, verdicts, the disposition -- and assembles the three columns: what it IS (Software Library from the declared distribution, Software Capability from the intended use, Endpoints from interface_surface with the implied/no- contract clause, Infrastructure Asset? from Dockerfiles), what it HOLDS (profiled data files, worthy sub-resources), what it is MADE OF (accepted components; review stays on Architecture verdicts), how it RELATES (dependencies; already-catalogued ones not looked up, said so), and the manifest of what a commit writes: testimony copied as classifications with author and date, measurements linked. Candidates with evidence, never auto-applied. The population rule -- tracking or using -- is on the plan, not hidden. The commit is a queued run (kind curate_commit). Each step -- publish the asset and link its survey report, copy Confidentiality/Criticality/ Retention from enrichment onto it with the author as steward, publish the confirmed sub-resources with their ancestor folders, components skipped because they materialize on acceptance -- writes its outcome to an append-only resource_curation record, so the screen shows the act while it runs and after, and a failure elsewhere is a failed step, not a lost act. Signed-in only. manifest_parse gains a fifth sub-parse: the distribution the manifests declare -- name, entry points, packages, publish workflow (Repo Handoff checks 2 and 3, folded into the step that already parses the file). Curate's first claim becomes a fact rather than an inference. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- .../resource_explorer/curate_plan.py | 343 ++++++++++++++++++ .../ingestion/distribution_parser.py | 143 ++++++++ .../resource_explorer/registry.py | 1 + .../resource_explorer/run_queue.py | 13 + .../surveyors/sub_surveyors/manifest_parse.py | 56 ++- .../resource_explorer/web/routes/projects.py | 82 +++++ .../workflows/curate_commit.py | 182 ++++++++++ .../resource-explorer/tests/test_curate.py | 250 +++++++++++++ .../tests/test_manifest_parse_surveyor.py | 10 +- 9 files changed, 1074 insertions(+), 6 deletions(-) create mode 100644 packages/resource-explorer/resource_explorer/curate_plan.py create mode 100644 packages/resource-explorer/resource_explorer/ingestion/distribution_parser.py create mode 100644 packages/resource-explorer/resource_explorer/workflows/curate_commit.py create mode 100644 packages/resource-explorer/tests/test_curate.py diff --git a/packages/resource-explorer/resource_explorer/curate_plan.py b/packages/resource-explorer/resource_explorer/curate_plan.py new file mode 100644 index 000000000..280e613f5 --- /dev/null +++ b/packages/resource-explorer/resource_explorer/curate_plan.py @@ -0,0 +1,343 @@ +"""Curate: what the catalogue should know about this resource, assembled +for review before the one near-irreversible act in the product. + +The design (Enrichment and Curate Wireframes, Repo Handoff item 6): one +screen, three columns answering one question -- what it IS (library, +capability, endpoints), what it HOLDS (data files, sub-resources), what it +is MADE OF (components, ports, wires) -- plus how it relates, and a +manifest of what the commit writes. A database answers the first two; a +repository answers all three. The screen is a review-and-commit, not a +form: almost everything on it was decided earlier, and this is where it is +seen assembled. + +Three rules from the design, held here: +- Candidates with evidence, never auto-applied. Every row names the + analysis it came from and its state; a candidate is `□` until a person + confirms it, and "3 Dockerfiles" carries a question mark, not + "Infrastructure Asset (suggested)". +- Testimony is COPIED (enrichment -> classifications on the asset, with + author and date, because it exists nowhere else); measurements are + LINKED (survey reports are already Egeria objects; copying them makes a + second truth). Unresolved things travel. +- Only worthy things get curated: the population is resources dispositioned + `tracking` or `using`. The pane says so rather than hiding the resource. + +Everything here is a local read: facts, findings, enrichment, verdicts, the +disposition. Nothing calls Egeria, so the plan renders when Egeria is down +and says which parts it could not know. +""" +from __future__ import annotations + +import json +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone + +from resource_explorer.registry import ProjectRegistry + +#: Curate's population, by disposition (Repo Handoff, model statement 5). +CURATE_POPULATION = ("tracking", "using") + +#: Enrichment judgements that become governance classifications on the asset, +#: and the Egeria level each value maps to. The levels are Egeria's default +#: GovernanceClassificationLevel sets; a value not in the map is copied as +#: notes on the classification rather than dropped. +CONFIDENTIALITY_LEVELS = {"public": 0, "internal": 1, "confidential": 2, "restricted": 4} +CRITICALITY_LEVELS = {"low": 1, "important": 2, "critical": 3} + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _fact(layer, slug: str, analysis_id: str) -> dict: + try: + facts = layer.facts(slug, [analysis_id]) + except Exception as exc: # a reader that raises is a state, not a crash + return {"analysis_id": analysis_id, "state": "unreadable", "headline": str(exc), "value": {}} + return facts[0].as_dict() if facts else {"analysis_id": analysis_id, "state": "never_run", "value": {}} + + +def _findings(fact: dict) -> list[dict]: + return list(((fact.get("value") or {}).get("findings")) or []) + + +def _detail(finding: dict) -> dict: + """A finding row carries its detail as `detail_json`; a fact's finding + carries `detail`. Read either.""" + if isinstance(finding.get("detail"), dict): + return finding["detail"] + try: + return json.loads(finding.get("detail_json") or "{}") or {} + except ValueError: + return {} + + +def _row(kind: str, label: str, *, evidence: str, source: str, state: str, count: int | None = None, + members: dict | None = None, candidate: bool = True, detail: dict | None = None) -> dict: + """One line on the screen. `state` is the analysis's own state; `candidate` + False means the row is a count, not a thing to confirm.""" + return {"kind": kind, "label": label, "evidence": evidence, "source": source, + "state": state, "count": count, "members": members, "candidate": candidate, + "detail": detail or {}} + + +def build_plan(registry: ProjectRegistry, slug: str) -> dict: + from resource_explorer.facts import FactLayer + + project = registry.get(slug) + if not project: + raise KeyError(slug) + layer = FactLayer(registry) + disp = registry.get_disposition(project.github_url) if project.github_url else None + disposition = (disp or {}).get("disposition") or "undecided" + + # ── what it is ───────────────────────────────────────────────────── + what_it_is: list[dict] = [] + dist = registry.query_findings(slug, "distribution") + if dist: + for d in dist: + det = _detail(d) + lab = det.get("name") or d["check_name"] + where = {"python": "PyPI", "javascript": "npm", "java": "Maven"}.get(det.get("ecosystem", ""), det.get("ecosystem", "")) + what_it_is.append(_row( + "SoftwareLibrary", f"Software Library · {lab}" + (f", on {where}" if d.get("label") == "published" else f" ({det.get('ecosystem','')}, not published)"), + evidence=d.get("summary", ""), source="manifest_parse", state="measured", + members={"analysis_id": "dependency_analysis"}, detail=det)) + else: + mp = _fact(layer, slug, "manifest_parse") + manifests = ((mp.get("value") or {}).get("dependencies") or {}).get("manifests") or [] + if manifests: + what_it_is.append(_row( + "SoftwareLibrary", "Software Library · name not read", + evidence=f"manifests present ({', '.join(manifests)}); the distribution name is read by the " + "manifest_parse step from 2026-09-12 — re-survey to have it", + source="manifest_parse", state=mp.get("state", ""), detail={"manifests": manifests})) + else: + what_it_is.append(_row("SoftwareLibrary", "Software Library?", + evidence="no dependency manifest found", source="manifest_parse", + state=mp.get("state", ""))) + + ctx = registry.get_context("repo", slug) or {} + enrichment = ctx.get("enrichment") or {} + use = (enrichment.get("intended_use") or {}).get("value") or ctx.get("purpose") or "" + if use: + who = (enrichment.get("intended_use") or {}).get("author", "") + what_it_is.append(_row("SoftwareCapability", f"Software Capability · {use}", + evidence=f"your note{' · ' + who if who else ''}", source="enrichment", + state="measured")) + else: + what_it_is.append(_row("SoftwareCapability", "Software Capability · no intended use recorded", + evidence="set Intended use on the Enrichment pane", source="enrichment", + state="needs_human")) + + iface = _fact(layer, slug, "interface_surface") + # interface_surface's vocabulary is `specified` / `implied` / `no` + kinds = [f for f in _findings(iface) if f.get("check_name") != "published_spec" and f.get("label") in ("specified", "implied")] + spec = next((f for f in _findings(iface) if f.get("check_name") == "published_spec"), None) + if kinds: + names = ", ".join(f["check_name"] for f in kinds) + contract = "no contract" if (spec and spec.get("label") == "no") else "contract published" + implied = all(f.get("label") == "implied" for f in kinds) + what_it_is.append(_row( + "Endpoint", f"Endpoint × {len(kinds)} · {names} · {'implied' if implied else 'specified'}, {contract}", + evidence="; ".join(f.get("summary", "") for f in kinds)[:400], source="interface_surface", + state=iface.get("state", ""), count=len(kinds), members={"analysis_id": "interface_surface"}, + detail={"interfaces": [f["check_name"] for f in kinds], "implied": implied})) + conv = _fact(layer, slug, "repo_conventions") + docker = next((f for f in _findings(conv) if f.get("check_name") == "deployment_docker"), None) + if docker and docker.get("label") == "pass": + files = (_detail(docker).get("files") or []) + what_it_is.append(_row( + "InfrastructureAsset", f"Infrastructure Asset? {len(files) or ''} Dockerfile{'s' if len(files) != 1 else ''}".replace("? ", "? "), + evidence=docker.get("summary", ""), source="repo_conventions", state=conv.get("state", ""), + detail={"files": files})) + + # ── what it holds ────────────────────────────────────────────────── + holds: list[dict] = [] + dfp = _fact(layer, slug, "data_file_profiling") + profiles = (dfp.get("value") or {}).get("profiles") or [] + with_schema = sum(1 for p in profiles if p.get("schema_json")) + holds.append(_row("DataFile", f"{len(profiles):,} data files · {with_schema:,} with a profiled schema", + evidence="datasets with schemas — the same objects a database would give you", + source="data_file_profiling", state=dfp.get("state", ""), count=len(profiles), + members={"analysis_id": "data_file_profiling"}, candidate=len(profiles) > 0)) + srs = _fact(layer, slug, "sub_resource_survey") + subs = _findings(srs) + worthy = [f for f in subs if f.get("label") == "worthy"] + rejected = [f for f in subs if f.get("label") == "not_worthy"] + holds.append(_row("SubResource", f"{len(worthy)} of {len(subs)} sub-resources worth cataloguing · {len(rejected)} not", + evidence="each becomes its own asset, related to this one", + source="sub_resource_survey", state=srs.get("state", ""), count=len(worthy), + members={"analysis_id": "sub_resource_survey"}, candidate=len(worthy) > 0, + detail={"worthy": [f["check_name"] for f in worthy]})) + + # ── what it is made of ───────────────────────────────────────────── + verdicts = registry.get_component_verdicts("repo", slug) + comp_v = {k: v for k, v in verdicts.items() if v.get("verdict_target", "component") == "component"} + bp_v = {k: v for k, v in verdicts.items() if v.get("verdict_target") == "blueprint"} + arch = _fact(layer, slug, "architecture_recovery") + comps = (arch.get("value") or {}).get("components") or [] + accepted = sum(1 for v in comp_v.values() if v.get("verdict") == "accepted") + made_of = [ + _row("Component", f"{accepted} of {len(comps):,} components accepted · {len(comp_v)} reviewed", + evidence="ports and wires are derived from the accepted components' interfaces and relationships; " + "review is per component and lives on Architecture verdicts (current UI) until it moves here", + source="architecture_recovery", state=arch.get("state", ""), count=accepted, + members={"analysis_id": "architecture_recovery"}, candidate=accepted > 0, + detail={"reviewed": len(comp_v), "blueprints_reviewed": len(bp_v), + "blueprints_accepted": sum(1 for v in bp_v.values() if v.get("verdict") == "accepted")}), + ] + + # ── how it relates ───────────────────────────────────────────────── + deps = _fact(layer, slug, "dependency_analysis") + n_deps = sum(len(v) for v in ((deps.get("value") or {}).get("by_ecosystem") or {}).values()) + relates = [ + _row("Dependency", f"{n_deps} dependencies · already-catalogued ones not looked up", + evidence="which of these are already assets in the catalogue is an Egeria search, not a local fact; not built yet", + source="dependency_analysis", state=deps.get("state", ""), count=n_deps, + members={"analysis_id": "dependency_analysis"}, candidate=False), + ] + + # ── what gets written ────────────────────────────────────────────── + classifications = [] + for key, name in (("sensitivity", "Confidentiality"), ("criticality", "Criticality"), ("retention", "Retention")): + f = enrichment.get(key) or {} + if f.get("value"): + classifications.append({"key": key, "classification": name, "value": f["value"], + "author": f.get("author", ""), "set_at": f.get("set_at", ""), + "interim": bool(f.get("interim")), "review": bool(f.get("review"))}) + owner = enrichment.get("owner") or {} + licence = enrichment.get("licence") or {} + published = registry.get_last_published_annotation_types(slug) or {} + writes = { + "entities": [r["kind"] for r in what_it_is if r["candidate"]], + "contained": {"data_files": len(profiles), "sub_resources": len(worthy)}, + "classifications": classifications, + "owner": {"value": owner.get("value", ""), "interim": bool(owner.get("interim")), "author": owner.get("author", "")}, + "licence": licence.get("value", ""), + "survey_reports_linked": len(published), + "last_published_at": max(published.values(), default=""), + "catalogued": bool(getattr(project, "egeria_asset_guid", "")), + "asset_guid": getattr(project, "egeria_asset_guid", "") or "", + } + + return { + "slug": slug, + "display_name": project.display_name, + "technology_type": "Git Repository", + "disposition": disposition, + "in_population": disposition in CURATE_POPULATION, + "population": list(CURATE_POPULATION), + "last_surveyed_at": getattr(project, "last_surveyed_at", "") or "", + "what_it_is": what_it_is, + "what_it_holds": holds, + "made_of": made_of, + "relates": relates, + "writes": writes, + "keeps_current": "Resource Explorer surveys, on the Automate schedule. No integration connector exists for this type.", + "commits": Curations(registry).for_resource("repo", slug), + } + + +# ── the record of the act ────────────────────────────────────────────── + +def _ensure_schema(conn) -> None: + conn.execute(""" + CREATE TABLE IF NOT EXISTS resource_curation ( + id TEXT PRIMARY KEY, + entity_type TEXT NOT NULL, + entity_slug TEXT NOT NULL, + author TEXT NOT NULL, + requested_at TEXT NOT NULL, + selection TEXT NOT NULL DEFAULT '{}', + manifest TEXT NOT NULL DEFAULT '{}', + state TEXT NOT NULL DEFAULT 'queued', + steps TEXT NOT NULL DEFAULT '[]', + finished_at TEXT NOT NULL DEFAULT '', + activity_id TEXT NOT NULL DEFAULT '' + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_resource_curation_entity " + "ON resource_curation(entity_type, entity_slug, requested_at)") + + +@dataclass +class CurationStep: + name: str + state: str = "pending" # pending | running | done | failed | skipped + detail: str = "" + at: str = "" + + +class Curations: + """Append-only record of each Catalogue → press: who, when, what was + selected, what the manifest said it would write, and how each step went. + The commit is asynchronous and can fail elsewhere (the design's point), + so the record is what the screen shows while it runs and after.""" + + def __init__(self, registry: ProjectRegistry): + self.registry = registry + + def _conn(self): + return self.registry._conn() + + def create(self, entity_type: str, slug: str, *, author: str, selection: dict, manifest: dict, + steps: list[str], activity_id: str = "") -> dict: + if not author: + raise ValueError("a curation needs an author") + cid = uuid.uuid4().hex + now = _now() + rows = [CurationStep(s).__dict__ for s in steps] + with self._conn() as conn: + _ensure_schema(conn) + conn.execute( + "INSERT INTO resource_curation (id, entity_type, entity_slug, author, requested_at, selection, manifest, state, steps, activity_id) " + "VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?)", + (cid, entity_type, slug, author, now, json.dumps(selection), json.dumps(manifest), json.dumps(rows), activity_id)) + return self.get(cid) + + def get(self, cid: str) -> dict | None: + with self._conn() as conn: + _ensure_schema(conn) + row = conn.execute("SELECT * FROM resource_curation WHERE id = ?", (cid,)).fetchone() + return self._decode(row) if row else None + + def for_resource(self, entity_type: str, slug: str) -> list[dict]: + with self._conn() as conn: + _ensure_schema(conn) + rows = conn.execute( + "SELECT * FROM resource_curation WHERE entity_type = ? AND entity_slug = ? ORDER BY requested_at DESC", + (entity_type, slug)).fetchall() + return [self._decode(r) for r in rows] + + def set_step(self, cid: str, name: str, state: str, detail: str = "") -> None: + cur = self.get(cid) + if not cur: + return + steps = cur["steps"] + for s in steps: + if s["name"] == name: + s.update({"state": state, "detail": detail[:2000], "at": _now()}) + with self._conn() as conn: + conn.execute("UPDATE resource_curation SET steps = ?, state = 'running' WHERE id = ?", + (json.dumps(steps), cid)) + + def finish(self, cid: str) -> dict: + cur = self.get(cid) + states = {s["state"] for s in cur["steps"]} + final = "failed" if "failed" in states else "done" + with self._conn() as conn: + conn.execute("UPDATE resource_curation SET state = ?, finished_at = ? WHERE id = ?", + (final, _now(), cid)) + return self.get(cid) + + @staticmethod + def _decode(row) -> dict: + d = dict(row) if not isinstance(row, dict) else dict(row) + for k in ("selection", "manifest", "steps"): + try: + d[k] = json.loads(d.get(k) or ("[]" if k == "steps" else "{}")) + except ValueError: + d[k] = [] if k == "steps" else {} + return d diff --git a/packages/resource-explorer/resource_explorer/ingestion/distribution_parser.py b/packages/resource-explorer/resource_explorer/ingestion/distribution_parser.py new file mode 100644 index 000000000..658d1abb6 --- /dev/null +++ b/packages/resource-explorer/resource_explorer/ingestion/distribution_parser.py @@ -0,0 +1,143 @@ +"""What this repository DECLARES itself to be, read from its manifests. + +Curate's first claim -- "Software Library · pyegeria, on PyPI" -- was +inference until now: interface_surface infers a CLI from a dependency on +click, and the repo name stands in for the distribution name. The +manifests declare both. `[project.name]` is the distribution name (often +not the repo name), `[project.scripts]` is the CLI entry point as a fact, +`packages` gives component boundaries, and a publish workflow establishes +that the library is really distributed and under which name (Repo +Handoff, checks 2 and 3 -- folded into the step that already parses the +file, as the handoff said to). + +One finding per declared distribution, kind="distribution": + check_name "python:pyegeria" / "javascript:@odpi/egeria-ui" + label "published" when a publish workflow names the ecosystem, + else "declared" + detail {name, ecosystem, manifest, scripts, packages, publish_workflow} +Nothing is inferred: a repo with no [project] table declares nothing, and +that is the finding's absence, not a guess. +""" +from __future__ import annotations + +import json +import re +from pathlib import Path + +from resource_explorer.ingestion.vendored import is_vendored_abs + +_PUBLISH_MARKERS = { + "python": ("pypa/gh-action-pypi-publish", "twine upload", "poetry publish", "uv publish", "hatch publish"), + "javascript": ("npm publish", "yarn publish", "pnpm publish"), + "java": ("publishToMavenCentral", "publishToSonatype", "gradle publish", "mvn deploy", "maven-deploy"), +} + + +def _publish_workflows(root: Path) -> dict[str, str]: + """ecosystem -> workflow file that publishes it, from .github/workflows.""" + out: dict[str, str] = {} + wf = root / ".github" / "workflows" + if not wf.is_dir(): + return out + for f in sorted(wf.glob("*.y*ml")): + try: + text = f.read_text(encoding="utf-8", errors="ignore") + except OSError: + continue + for eco, markers in _PUBLISH_MARKERS.items(): + if eco not in out and any(m in text for m in markers): + out[eco] = f.name + return out + + +def _pyproject(path: Path) -> dict | None: + try: + import tomllib + data = tomllib.loads(path.read_text(encoding="utf-8", errors="ignore")) + except Exception: + return None + project = data.get("project") or {} + name = project.get("name") or (data.get("tool", {}).get("poetry", {}) or {}).get("name") + if not name: + return None + scripts = sorted((project.get("scripts") or {}).keys()) + if not scripts: + scripts = sorted(((data.get("tool", {}).get("poetry", {}) or {}).get("scripts") or {}).keys()) + packages: list[str] = [] + tool = data.get("tool", {}) or {} + st = tool.get("setuptools", {}) or {} + if isinstance(st.get("packages"), list): + packages = [p for p in st["packages"] if isinstance(p, str)] + elif isinstance((tool.get("hatch", {}).get("build", {}).get("targets", {}).get("wheel", {}) or {}).get("packages"), list): + packages = list(tool["hatch"]["build"]["targets"]["wheel"]["packages"]) + return {"name": str(name), "ecosystem": "python", "scripts": scripts, "packages": packages, + "version": str(project.get("version") or "")} + + +def _package_json(path: Path) -> dict | None: + try: + data = json.loads(path.read_text(encoding="utf-8", errors="ignore")) + except Exception: + return None + if not isinstance(data, dict) or not data.get("name") or data.get("private") is True: + return None + bin_ = data.get("bin") + scripts = sorted(bin_.keys()) if isinstance(bin_, dict) else ([data["name"]] if isinstance(bin_, str) else []) + return {"name": str(data["name"]), "ecosystem": "javascript", "scripts": scripts, "packages": [], + "version": str(data.get("version") or "")} + + +_GRADLE_GROUP = re.compile(r"^\s*group\s*=?\s*['\"]([^'\"]+)['\"]", re.M) +_GRADLE_ARCHIVES = re.compile(r"archivesBaseName\s*=?\s*['\"]([^'\"]+)['\"]") + + +def _gradle(path: Path) -> dict | None: + try: + text = path.read_text(encoding="utf-8", errors="ignore") + except OSError: + return None + group = _GRADLE_GROUP.search(text) + if not group: + return None + base = _GRADLE_ARCHIVES.search(text) + name = f"{group.group(1)}:{base.group(1) if base else path.parent.name}" + return {"name": name, "ecosystem": "java", "scripts": [], "packages": [], "version": ""} + + +_READERS = {"pyproject.toml": _pyproject, "package.json": _package_json, + "build.gradle": _gradle, "build.gradle.kts": _gradle} + + +class DistributionParser: + def parse(self, root: Path) -> list[dict]: + publish = _publish_workflows(root) + findings: list[dict] = [] + seen: set[str] = set() + # Shallowest first, so the root manifest wins the dedupe over a + # nested copy of the same distribution. + for p in sorted(root.rglob("*"), key=lambda q: (len(q.parts), str(q))): + if not p.is_file() or p.name not in _READERS or is_vendored_abs(p, root): + continue + d = _READERS[p.name](p) + if not d: + continue + key = f"{d['ecosystem']}:{d['name']}" + if key in seen: + continue + seen.add(key) + rel = str(p.relative_to(root)) + wf = publish.get(d["ecosystem"], "") + bits = [f"{d['name']} — {d['ecosystem']} distribution declared in {rel}"] + if d["scripts"]: + bits.append(f"{len(d['scripts'])} command-line entry point(s): {', '.join(d['scripts'][:6])}") + if d["packages"]: + bits.append(f"{len(d['packages'])} package(s)") + bits.append(f"publish workflow: {wf}" if wf else "no publish workflow found") + findings.append({ + "check_name": key, + "label": "published" if wf else "declared", + "summary": "; ".join(bits) + ".", + "confidence": 100, + "detail": {**d, "manifest": rel, "publish_workflow": wf}, + }) + return findings diff --git a/packages/resource-explorer/resource_explorer/registry.py b/packages/resource-explorer/resource_explorer/registry.py index bf08dc963..a703c5178 100644 --- a/packages/resource-explorer/resource_explorer/registry.py +++ b/packages/resource-explorer/resource_explorer/registry.py @@ -2396,6 +2396,7 @@ def _init_schema(self) -> None: "scouting_scan", "stage_batch", "discovery_expand", + "curate_commit", ) #: States that occupy a user's one fairness slot. diff --git a/packages/resource-explorer/resource_explorer/run_queue.py b/packages/resource-explorer/resource_explorer/run_queue.py index 5f5033a81..84d7a9909 100644 --- a/packages/resource-explorer/resource_explorer/run_queue.py +++ b/packages/resource-explorer/resource_explorer/run_queue.py @@ -208,7 +208,20 @@ def _handle_discovery_expand(target: dict, result_ref: str) -> RunOutcome: return RunOutcome(state="succeeded") +def _handle_curate_commit(target: dict, result_ref: str) -> RunOutcome: + """Catalogue →. Every step writes its outcome to the curation record; + the run fails only if a step did (the record says which).""" + from resource_explorer.registry import ProjectRegistry + from resource_explorer.workflows.curate_commit import execute_curation + + rec = execute_curation(ProjectRegistry(), target["curation_id"]) + failed = [s["name"] for s in rec.get("steps", []) if s.get("state") == "failed"] + return RunOutcome(state="failed" if failed else "succeeded", + error=("failed: " + ", ".join(failed)) if failed else "") + + HANDLERS: dict[str, Callable[[dict, str], RunOutcome]] = { + "curate_commit": _handle_curate_commit, "analysis_run": _handle_analysis_run, "stage_batch": _handle_stage_batch, "scouting_scan": _handle_scouting_scan, diff --git a/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/manifest_parse.py b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/manifest_parse.py index b20ddd44c..eb8e026f1 100644 --- a/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/manifest_parse.py +++ b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/manifest_parse.py @@ -118,13 +118,67 @@ def run(self) -> list[Annotation]: # them: a bug in the CI-workflow parser must not cost the dependency # write, and vice versa. This is the isolation contract # file_inventory.py and symbol_extraction.py don't need (they write one - # table each) but this step does, since it bundles four. + # table each) but this step does, since it bundles five. results.append(self._parse_dependencies(local_root)) results.append(self._parse_ci_quality(local_root)) results.append(self._parse_repo_conventions(local_root)) results.append(self._parse_supply_chain(local_root)) + results.append(self._parse_distribution(local_root)) return results + def _parse_distribution(self, local_root: Path) -> Annotation: + """What the repository declares itself to be: distribution name, + entry points, packages, publish workflow. Curate's first claim as a + fact rather than an inference (Repo Handoff checks 2 and 3).""" + slug = self.project.slug + try: + from resource_explorer.ingestion.distribution_parser import DistributionParser + + findings = DistributionParser().parse(local_root) + if findings: + self.registry.upsert_finding(slug, "distribution", findings, + surveyed_at=self._surveyed_at) + outcome = StepOutcome(RECOVERED, known_positive=True, + detail={"declared": len(findings)}) + summary = (f"{len(findings)} declared distribution(s): " + + ", ".join(f["detail"]["name"] for f in findings[:4])) + else: + # A repo with no [project] table / package.json name declares + # nothing. A real zero when a manifest exists; unverified + # when none does, because there was nothing to read. + manifests = _manifests_present(local_root) + if manifests: + outcome = no_signal("manifests present but none declares a distribution name", + known_positive=True) + else: + outcome = StepOutcome(UNVERIFIED, cause="no_manifest_found") + summary = "No declared distribution" + self._record_snapshot("manifest_parse_distribution", len(findings), outcome) + return ResourceMeasureAnnotation( + check_name="distribution_manifest", + summary=summary, analysis_step=STEP, + confidence=100 if outcome.is_conclusive else 50, + explanation=( + "Refreshed project_analysis_findings (kind=\"distribution\") from " + "a fresh zipball extraction — the distribution name, entry points, " + "packages and publish workflow the manifests declare, which Curate " + "reads for its first claim." + ), + resource_properties={"finding_count": len(findings)}, + json_properties=outcome.as_row(), + ) + except Exception as exc: + log.warning("ManifestParseSurveyor distribution parse failed for %s: %s", slug, exc) + outcome = StepOutcome(UNVERIFIED, cause="parse_error", detail={"error": str(exc)}) + self._record_snapshot("manifest_parse_distribution", 0, outcome) + return ResourceMeasureAnnotation( + check_name="distribution_manifest", + summary="Distribution parse failed", analysis_step=STEP, confidence=0, + explanation=f"Could not parse declared distributions: {exc}", + resource_properties={"finding_count": 0, "error": str(exc)}, + json_properties=outcome.as_row(), + ) + def _record_snapshot(self, kind: str, count: int, outcome: StepOutcome) -> None: """Generic project_analysis_metrics snapshot for this sub-parse's own run history — same pattern SymbolExtractionSurveyor uses for its diff --git a/packages/resource-explorer/resource_explorer/web/routes/projects.py b/packages/resource-explorer/resource_explorer/web/routes/projects.py index 1d4132fbc..e3a6c18a6 100644 --- a/packages/resource-explorer/resource_explorer/web/routes/projects.py +++ b/packages/resource-explorer/resource_explorer/web/routes/projects.py @@ -1510,3 +1510,85 @@ def promote_members(slug: str, analysis_id: str, body: PromoteSelection, request entry = Journal(registry).write("repo", slug, author=author, body=f"{name}. {line}", suggest_to=body.suggest_to) return {"action": "journal", "name": name, "provenance": line, "journal": entry.get("id"), "work_lists": entry.get("work_lists", [])} + + +# ── Curate: review-and-commit ─────────────────────────────────────────── +# +# One screen, three columns, one commit. The plan is a local read (facts, +# findings, enrichment, verdicts, disposition) so it renders when Egeria is +# down; the commit is a queued run whose steps write their outcomes to the +# curation record as they land, because a handoff is asynchronous and can +# fail elsewhere. See curate_plan.py for the design rules held. + +class CurateSelection(BaseModel): + confirm: list[str] = Field(default_factory=list) # kinds from what_it_is: SoftwareLibrary, Endpoint, ... + sub_resources: list[str] = Field(default_factory=list) # locators from the sub-resource survey + data_files: bool = False # contained datasets -- recorded in the manifest; publish path not built + note: str = "" + + +@router.get("/{slug}/curate/plan") +def curate_plan(slug: str) -> dict: + from resource_explorer.curate_plan import build_plan + from resource_explorer.registry import ProjectRegistry + try: + return build_plan(ProjectRegistry(), slug) + except KeyError: + raise HTTPException(status_code=404, detail=f"Project '{slug}' not found") + + +@router.post("/{slug}/curate/commit") +def curate_commit(slug: str, body: CurateSelection, request: Request) -> dict: + """Catalogue →. Records the act (who, when, what was selected, what the + manifest said), logs an activity entry the pane polls, and enqueues the + run. Signed-in only: the record needs an author, and Ownership on the + asset is curation by default.""" + from resource_explorer.activity_logger import log_survey + from resource_explorer.auth import get_current_user + from resource_explorer.curate_plan import CURATE_POPULATION, Curations, build_plan + from resource_explorer.registry import ProjectRegistry + from resource_explorer.workflows.curate_commit import STEPS + + user = get_current_user(request) + author = (user or {}).get("user_id") or (user or {}).get("sub") or (user or {}).get("username") or "" + if not author: + raise HTTPException(status_code=401, detail="Sign in to catalogue — the record needs an author, and the asset an owner.") + registry = ProjectRegistry() + project = registry.get(slug) + if not project: + raise HTTPException(status_code=404, detail=f"Project '{slug}' not found") + plan = build_plan(registry, slug) + if not plan["in_population"]: + raise HTTPException(status_code=409, detail=( + f"Only worthy things get curated: Curate's population is disposition {' or '.join(CURATE_POPULATION)}, " + f"and this resource is '{plan['disposition']}'. Set its disposition first.")) + known = {r["kind"] for r in plan["what_it_is"] if r["candidate"]} + unknown = [k for k in body.confirm if k not in known] + if unknown: + raise HTTPException(status_code=400, detail=f"Not candidates on this resource: {unknown}") + manifest = {**plan["writes"], "entities": list(body.confirm), + "contained": {"data_files": plan["writes"]["contained"]["data_files"] if body.data_files else 0, + "sub_resources": len(body.sub_resources)}} + activity_id = log_survey( + registry, entity_type="repo", entity_slug=slug, + entity_name=project.display_name, entity_location=project.github_url, + intent="curate", status="running", + summary=f"Cataloguing {project.display_name}: {len(body.confirm)} entities, {len(body.sub_resources)} sub-resources…", + ) + rec = Curations(registry).create( + "repo", slug, author=author, selection=body.model_dump(), manifest=manifest, + steps=list(STEPS), activity_id=activity_id) + run_id = registry.enqueue_run("curate_commit", {"slug": slug, "curation_id": rec["id"]}, + result_ref=activity_id, requested_by=_requested_by()) + log.info("enqueued curate_commit %s for %s (activity %s)", run_id, slug, activity_id) + return {"curation": rec, "activity_id": activity_id, "run_id": run_id} + + +@router.get("/{slug}/curate/commits/{curation_id}") +def curate_commit_status(slug: str, curation_id: str) -> dict: + from resource_explorer.curate_plan import Curations + from resource_explorer.registry import ProjectRegistry + rec = Curations(ProjectRegistry()).get(curation_id) + if not rec or rec["entity_slug"] != slug: + raise HTTPException(status_code=404, detail="No such curation") + return rec diff --git a/packages/resource-explorer/resource_explorer/workflows/curate_commit.py b/packages/resource-explorer/resource_explorer/workflows/curate_commit.py new file mode 100644 index 000000000..ffa732884 --- /dev/null +++ b/packages/resource-explorer/resource_explorer/workflows/curate_commit.py @@ -0,0 +1,182 @@ +"""Catalogue →: the commit behind the Curate screen, run asynchronously. + +The design's three consequences of Curate being a handoff rather than a +state change inside RE: the commit is asynchronous and can fail elsewhere; +"running · N targets" is read, not known; after Curate RE is not the +source of truth. So the commit is a queued run, each step writes its +outcome to the curation record as it lands, and the screen shows the +record. Nothing here raises past a step: a step that fails is a failed +step on the record, and the next step runs unless it depends on it. + +Steps, in order: + publish_asset survey + publish, the existing publish path -- creates + or finds the SourceControlLibrary asset and links a + SurveyReport (measurements LINKED, not copied) + classifications enrichment judgements COPIED onto the asset as + Confidentiality / Criticality / Retention, with the + author as steward and the date in the notes -- they + exist nowhere else + sub_resources the worthy sub-resources the person confirmed, as + FileFolder/DataFile assets under the repo's + components skipped here: accepted components materialize when + they are accepted (workflows/curate.py); the record + says so rather than pretending to have done it +""" +from __future__ import annotations + +import logging +from datetime import datetime, timezone + +from resource_explorer.curate_plan import CONFIDENTIALITY_LEVELS, CRITICALITY_LEVELS, Curations +from resource_explorer.registry import ProjectRegistry + +log = logging.getLogger(__name__) + +STEPS = ("publish_asset", "classifications", "sub_resources", "components") + + +def _classification_bodies(enrichment: dict) -> list[tuple[str, str, dict]]: + """(step-method name, human name, body) for each judgement that maps to a + governance classification. A value outside the level map is not + dropped -- it goes into `notes` with level 0, because testimony is + copied, not normalised away.""" + out = [] + stamp = datetime.now(timezone.utc).isoformat(timespec="seconds") + + def common(f): + return {"steward": f.get("author", "") or "", "stewardTypeName": "UserIdentity", + "stewardPropertyName": "userId", "source": "resource-explorer enrichment", + "confidence": 100, + "notes": f"{f.get('value','')} — set by {f.get('author','?')} on {(f.get('set_at') or '')[:10]}" + f"{' · interim' if f.get('interim') else ''}; copied to the catalogue {stamp}"} + + f = enrichment.get("sensitivity") or {} + if f.get("value"): + out.append(("set_confidentiality_classification", f"Confidentiality · {f['value']}", { + "class": "NewClassificationRequestBody", + "properties": {"class": "ConfidentialityProperties", + "confidentialityLevel": CONFIDENTIALITY_LEVELS.get(str(f["value"]).lower(), 0), + "statusIdentifier": 0, **common(f)}})) + f = enrichment.get("criticality") or {} + if f.get("value"): + out.append(("set_criticality_classification", f"Criticality · {f['value']}", { + "class": "NewClassificationRequestBody", + "properties": {"class": "CriticalityProperties", + "criticalityLevel": CRITICALITY_LEVELS.get(str(f["value"]).lower(), 0), + "statusIdentifier": 0, **common(f)}})) + f = enrichment.get("retention") or {} + if f.get("value"): + out.append(("set_retention_classification", f"Retention · {f['value']}", { + "class": "NewClassificationRequestBody", + "properties": {"class": "RetentionClassificationProperties", + "retentionBasis": str(f["value"]), "status": "ACTIVE", **common(f)}})) + return out + + +def execute_curation(registry: ProjectRegistry, curation_id: str) -> dict: + cur = Curations(registry) + rec = cur.get(curation_id) + if not rec: + raise KeyError(curation_id) + slug = rec["entity_slug"] + sel = rec.get("selection") or {} + project = registry.get(slug) + if not project: + cur.set_step(curation_id, "publish_asset", "failed", "resource no longer registered") + return cur.finish(curation_id) + + # ── 1. the asset and its survey report ───────────────────────────── + asset_guid = "" + cur.set_step(curation_id, "publish_asset", "running") + try: + context = registry.get_project_context("repo", slug) + if not context or context.get("status") == "unset": + inherited = registry.inherited_egeria_project_context("repo", slug) + if not inherited: + raise RuntimeError("no Egeria Project context — decide it on the Scouting pane (or bind the investigation) and press Catalogue again") + registry.set_project_context( + "repo", slug, status="linked", + egeria_project_guid=inherited["egeria_project_guid"], + egeria_project_qualified_name=inherited["egeria_project_qualified_name"], + free_text_name=f"inherited from investigation '{inherited['_inherited_from_name']}'") + from resource_explorer.surveyors.egeria_publisher import EgeriaPublisher + from resource_explorer.surveyors.survey_orchestrator import SurveyOrchestrator + survey = SurveyOrchestrator(registry=registry).run(slug, steps=None) + import asyncio + loop = asyncio.new_event_loop(); asyncio.set_event_loop(loop) + try: + report_guid = EgeriaPublisher(registry=registry).publish(survey) + finally: + loop.close(); asyncio.set_event_loop(None) + asset_guid = registry.get_egeria_asset_guid(slug) or "" + cur.set_step(curation_id, "publish_asset", "done", + f"asset {asset_guid or '?'} · survey report {report_guid} · {len(survey.annotations)} annotations linked") + except Exception as exc: + log.warning("curation %s: publish failed for %s: %s", curation_id, slug, exc) + cur.set_step(curation_id, "publish_asset", "failed", f"{type(exc).__name__}: {exc}") + asset_guid = registry.get_egeria_asset_guid(slug) or "" + + # ── 2. testimony, copied ─────────────────────────────────────────── + ctx = registry.get_context("repo", slug) or {} + bodies = _classification_bodies(ctx.get("enrichment") or {}) + if not bodies: + cur.set_step(curation_id, "classifications", "skipped", "no sensitivity, criticality or retention set on the Enrichment pane") + elif not asset_guid: + cur.set_step(curation_id, "classifications", "failed", "no asset to classify — the publish step did not produce one") + else: + cur.set_step(curation_id, "classifications", "running") + from resource_explorer.egeria_identity import classification_client + done, failed = [], [] + try: + client = classification_client() + for method, name, body in bodies: + try: + getattr(client, method)(asset_guid, body) + done.append(name) + except Exception as exc: + failed.append(f"{name}: {type(exc).__name__}: {exc}"[:200]) + except Exception as exc: + failed.append(f"no classification client: {exc}"[:200]) + cur.set_step(curation_id, "classifications", "failed" if failed else "done", + " · ".join(done + failed)) + + # ── 3. what it holds ─────────────────────────────────────────────── + locators = list(sel.get("sub_resources") or []) + if not locators: + cur.set_step(curation_id, "sub_resources", "skipped", "none selected") + elif not asset_guid: + cur.set_step(curation_id, "sub_resources", "failed", "no parent asset — the publish step did not produce one") + else: + cur.set_step(curation_id, "sub_resources", "running") + try: + from resource_explorer.surveyors.egeria_publisher import EgeriaPublisher + # Local cataloguing first: publish_sub_resources publishes only + # rows already in the local sub_resources table, and NestedFile + # needs every ancestor folder catalogued too -- the same two + # rules the catalog route (routes/projects.py) enforces. + from resource_explorer.surveyors.sub_surveyors import ancestor_folder_paths + import json as _json + kinds: dict[str, str] = {} + for f in registry.query_findings(slug, "repo_sub_resource_survey"): + try: + kinds[f["check_name"]] = (_json.loads(f.get("detail_json") or "{}").get("kind") or "folder") + except ValueError: + kinds[f["check_name"]] = "folder" + want: dict[str, str] = {loc: kinds.get(loc, "folder") for loc in locators} + for loc, kind in list(want.items()): + if kind == "file": + for anc in ancestor_folder_paths(loc): + want.setdefault(anc, "folder") + for loc in sorted(want): + registry.catalog_sub_resource("repo", slug, loc, want[loc], source_finding="repo_sub_resource_survey") + guids = EgeriaPublisher(registry=registry).publish_sub_resources(slug, project.github_url, asset_guid, sorted(want)) + missing = [l for l in locators if l not in guids] + cur.set_step(curation_id, "sub_resources", "failed" if missing else "done", + f"{len(guids)} of {len(locators)} published" + (f" · not published: {', '.join(missing[:8])}" if missing else "")) + except Exception as exc: + cur.set_step(curation_id, "sub_resources", "failed", f"{type(exc).__name__}: {exc}") + + # ── 4. what it is made of ────────────────────────────────────────── + cur.set_step(curation_id, "components", "skipped", + "accepted components materialize at the moment they are accepted (Architecture verdicts); nothing to do at commit") + return cur.finish(curation_id) diff --git a/packages/resource-explorer/tests/test_curate.py b/packages/resource-explorer/tests/test_curate.py new file mode 100644 index 000000000..d659fbf3d --- /dev/null +++ b/packages/resource-explorer/tests/test_curate.py @@ -0,0 +1,250 @@ +"""Curate — the plan, the record, the commit, and the population rule. + +Repo Handoff item 6 and the Curate wireframe: three columns answering +one question, candidates with evidence never auto-applied, testimony +copied and measurements linked, only worthy things curated. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from resource_explorer.curate_plan import CURATE_POPULATION, Curations, build_plan +from resource_explorer.registry import Project, ProjectRegistry + + +@pytest.fixture +def registry(tmp_path): + r = ProjectRegistry(db_path=str(tmp_path / "t.db")) + r.add(Project(slug="p", display_name="P repo", github_url="https://github.com/x/p", description="")) + return r + + +@pytest.fixture +def client(registry, monkeypatch): + monkeypatch.setattr("resource_explorer.registry.ProjectRegistry.__init__", + lambda self, db_path=None: setattr(self, "__dict__", registry.__dict__) or None) + monkeypatch.setenv("TRELLIS_ANONYMOUS_READ", "true") + monkeypatch.setattr("resource_explorer.auth.get_current_user", lambda request: {"user_id": "peterprofile"}) + from resource_explorer.web.app import app + return TestClient(app) + + +def _seed(registry): + """A repo with a declared distribution, implied interfaces, Dockerfiles, + sub-resources, and one enrichment judgement.""" + registry.upsert_finding("p", "distribution", [{ + "check_name": "python:pyegeria", "label": "published", + "summary": "pyegeria — python distribution declared in pyproject.toml; 3 command-line entry point(s): a, b, c; publish workflow: release.yml.", + "detail": {"name": "pyegeria", "ecosystem": "python", "scripts": ["a", "b", "c"], "packages": [], "manifest": "pyproject.toml", "publish_workflow": "release.yml"}}]) + registry.upsert_finding("p", "interface_surface", [ + {"check_name": "cli", "label": "implied", "summary": "Depends on click — suggests a cli"}, + {"check_name": "http_api", "label": "implied", "summary": "Depends on fastapi — suggests a http api"}, + {"check_name": "published_spec", "label": "no", "summary": "No specification file found"}]) + registry.upsert_finding("p", "repo_conventions", [ + {"check_name": "deployment_docker", "label": "pass", "summary": "Deployment/container evidence found", "detail": {"files": ["Dockerfile", "a/Dockerfile"]}}]) + registry.upsert_finding("p", "repo_sub_resource_survey", [ + {"check_name": "docs", "label": "worthy", "summary": "top_level_structural_folder", "detail": {"path": "docs", "kind": "folder"}}, + {"check_name": "docs/guide.md", "label": "worthy", "summary": "well_known_file", "detail": {"path": "docs/guide.md", "kind": "file"}}, + {"check_name": ".idea", "label": "not_worthy", "summary": "generated", "detail": {"path": ".idea", "kind": "folder"}}]) + # The fact layer credits an analysis only for a recorded run; without + # these, everything above reads as never_run and the plan stays honest + # about it -- which is the third test below. + from resource_explorer.activity_logger import log_analysis_run + for aid in ("interface_surface", "repo_conventions", "sub_resource_survey", "data_file_profiling", + "architecture_recovery", "dependency_analysis", "manifest_parse"): + log_analysis_run(registry, "repo", "p", "P repo", "success", f"{aid} ran", aid) + registry.save_context("repo", "p", {"enrichment": { + "sensitivity": {"value": "internal", "kind": "judgement", "author": "peterprofile", "set_at": "2026-09-01T00:00:00+00:00"}, + "owner": {"value": "peterprofile", "kind": "judgement", "author": "peterprofile", "set_at": "2026-09-01T00:00:00+00:00", "interim": True}, + }}) + + +class TestThePlan: + def test_the_three_columns_are_candidates_with_evidence(self, registry): + _seed(registry) + plan = build_plan(registry, "p") + kinds = {r["kind"]: r for r in plan["what_it_is"]} + assert kinds["SoftwareLibrary"]["label"] == "Software Library · pyegeria, on PyPI" + assert kinds["SoftwareLibrary"]["source"] == "manifest_parse" + assert kinds["Endpoint"]["label"] == "Endpoint × 2 · cli, http_api · implied, no contract" + assert kinds["InfrastructureAsset"]["label"] == "Infrastructure Asset? 2 Dockerfiles" # a question mark, not "(suggested)" + assert kinds["SoftwareCapability"]["state"] == "needs_human" # no intended use recorded + assert all(r["candidate"] for r in plan["what_it_is"]) + assert all(r["source"] and r["evidence"] for r in plan["what_it_is"]) + holds = {r["kind"]: r for r in plan["what_it_holds"]} + assert holds["SubResource"]["count"] == 2 and holds["SubResource"]["label"].startswith("2 of 3 sub-resources") + assert holds["DataFile"]["candidate"] is False # none profiled: a count of zero is not a thing to confirm + assert plan["relates"][0]["candidate"] is False # already-catalogued deps: not looked up, said so + + def test_the_manifest_copies_testimony_and_links_measurements(self, registry): + _seed(registry) + w = build_plan(registry, "p")["writes"] + assert w["classifications"] == [{"key": "sensitivity", "classification": "Confidentiality", "value": "internal", + "author": "peterprofile", "set_at": "2026-09-01T00:00:00+00:00", "interim": False, "review": False}] + assert w["owner"] == {"value": "peterprofile", "interim": True, "author": "peterprofile"} + assert w["catalogued"] is False and w["survey_reports_linked"] == 0 + + def test_the_population_is_tracking_or_using_and_the_plan_says_which(self, registry): + plan = build_plan(registry, "p") + assert plan["disposition"] == "undecided" and plan["in_population"] is False + registry.set_disposition("https://github.com/x/p", "tracking", resource_slug="p") + assert build_plan(registry, "p")["in_population"] is True + assert tuple(CURATE_POPULATION) == ("tracking", "using") + + def test_a_name_not_yet_read_is_said_not_guessed(self, registry): + registry.upsert_finding("p", "manifest_parse", []) + registry.upsert_metric("p", "manifest_parse", {"dependency_count": 3}, detail={"dependencies": {"manifests": ["pyproject.toml"]}}) + row = next(r for r in build_plan(registry, "p")["what_it_is"] if r["kind"] == "SoftwareLibrary") + assert "not read" in row["label"] or "Software Library?" in row["label"] + + +class TestTheRecord: + def test_the_record_is_append_only_and_tracks_each_step(self, registry): + c = Curations(registry) + rec = c.create("repo", "p", author="peterprofile", selection={"confirm": ["SoftwareLibrary"]}, + manifest={"entities": ["SoftwareLibrary"]}, steps=["publish_asset", "classifications"]) + assert rec["state"] == "queued" and [s["state"] for s in rec["steps"]] == ["pending", "pending"] + c.set_step(rec["id"], "publish_asset", "done", "asset 123") + c.set_step(rec["id"], "classifications", "failed", "no client") + out = c.finish(rec["id"]) + assert out["state"] == "failed" and out["finished_at"] + assert [s["state"] for s in out["steps"]] == ["done", "failed"] + assert c.for_resource("repo", "p")[0]["id"] == rec["id"] + with pytest.raises(ValueError): + c.create("repo", "p", author="", selection={}, manifest={}, steps=[]) + + +class TestTheCommitRoute: + def test_it_refuses_outside_the_population(self, client, registry): + _seed(registry) + r = client.post("/api/projects/p/curate/commit", json={"confirm": ["SoftwareLibrary"]}) + assert r.status_code == 409 and "tracking or using" in r.json()["detail"] + + def test_it_records_the_act_and_enqueues_the_run(self, client, registry): + _seed(registry) + registry.set_disposition("https://github.com/x/p", "using", resource_slug="p") + r = client.post("/api/projects/p/curate/commit", + json={"confirm": ["SoftwareLibrary", "Endpoint"], "sub_resources": ["docs"], "note": "go"}) + assert r.status_code == 200, r.text + body = r.json() + rec = body["curation"] + assert rec["author"] == "peterprofile" and rec["state"] == "queued" + assert rec["manifest"]["entities"] == ["SoftwareLibrary", "Endpoint"] + assert rec["manifest"]["contained"] == {"data_files": 0, "sub_resources": 1} + assert [s["name"] for s in rec["steps"]] == ["publish_asset", "classifications", "sub_resources", "components"] + run = registry.get_run(body["run_id"]) if hasattr(registry, "get_run") else None + if run is not None: + assert run["kind"] == "curate_commit" and json.loads(run["target"])["curation_id"] == rec["id"] + assert client.get(f"/api/projects/p/curate/commits/{rec['id']}").json()["id"] == rec["id"] + + def test_a_non_candidate_is_refused(self, client, registry): + _seed(registry) + registry.set_disposition("https://github.com/x/p", "using", resource_slug="p") + r = client.post("/api/projects/p/curate/commit", json={"confirm": ["Spaceship"]}) + assert r.status_code == 400 + + def test_anonymous_is_refused(self, client, registry, monkeypatch): + monkeypatch.setattr("resource_explorer.auth.get_current_user", lambda request: None) + assert client.post("/api/projects/p/curate/commit", json={"confirm": []}).status_code == 401 + + +class TestTheCommitSteps: + """The workflow with Egeria faked: each step lands on the record, a + failure is a failed step and the rest still run, and the + classifications carry the author and date.""" + + def test_each_step_writes_its_outcome(self, registry, monkeypatch): + _seed(registry) + from resource_explorer.workflows import curate_commit as wf + registry.set_project_context("repo", "p", status="personal") + calls = [] + + class FakeSurvey: + annotations = [1, 2, 3] + + class FakeOrch: + def __init__(self, registry=None): pass + def run(self, slug, steps=None): return FakeSurvey() + + class FakePublisher: + def __init__(self, registry=None): pass + def publish(self, survey): + registry.set_egeria_asset_guid("p", "asset-1") if hasattr(registry, "set_egeria_asset_guid") else None + return "report-9" + def publish_sub_resources(self, slug, url, guid, locators): + calls.append(("subs", guid, list(locators))) + return {l: f"g-{l}" for l in locators} + + class FakeClient: + def set_confidentiality_classification(self, guid, body): + calls.append(("conf", guid, body["properties"]["confidentialityLevel"], body["properties"]["notes"])) + + monkeypatch.setattr("resource_explorer.surveyors.survey_orchestrator.SurveyOrchestrator", FakeOrch) + monkeypatch.setattr("resource_explorer.surveyors.egeria_publisher.EgeriaPublisher", FakePublisher) + monkeypatch.setattr("resource_explorer.egeria_identity.classification_client", lambda *a, **k: FakeClient()) + monkeypatch.setattr(registry, "get_egeria_asset_guid", lambda slug: "asset-1") + + rec = Curations(registry).create("repo", "p", author="peterprofile", + selection={"confirm": ["SoftwareLibrary"], "sub_resources": ["docs/guide.md"]}, + manifest={}, steps=list(wf.STEPS)) + out = wf.execute_curation(registry, rec["id"]) + by = {s["name"]: s for s in out["steps"]} + assert by["publish_asset"]["state"] == "done" and "report-9" in by["publish_asset"]["detail"] + assert by["classifications"]["state"] == "done" and "Confidentiality · internal" in by["classifications"]["detail"] + assert by["sub_resources"]["state"] == "done" + assert by["components"]["state"] == "skipped" + assert out["state"] == "done" + conf = next(c for c in calls if c[0] == "conf") + assert conf[2] == 1 and "set by peterprofile on 2026-09-01" in conf[3] + subs = next(c for c in calls if c[0] == "subs") + assert subs[2] == ["docs", "docs/guide.md"] # the ancestor folder was catalogued too + assert {r["locator"] for r in registry.list_sub_resources("repo", "p")} == {"docs", "docs/guide.md"} + + def test_a_failed_publish_is_a_failed_step_and_the_rest_say_why(self, registry, monkeypatch): + _seed(registry) + from resource_explorer.workflows import curate_commit as wf + registry.set_project_context("repo", "p", status="personal") + + class Boom: + def __init__(self, registry=None): pass + def run(self, slug, steps=None): raise RuntimeError("Egeria unreachable") + + monkeypatch.setattr("resource_explorer.surveyors.survey_orchestrator.SurveyOrchestrator", Boom) + rec = Curations(registry).create("repo", "p", author="peterprofile", + selection={"sub_resources": ["docs"]}, manifest={}, steps=list(wf.STEPS)) + out = wf.execute_curation(registry, rec["id"]) + by = {s["name"]: s for s in out["steps"]} + assert by["publish_asset"]["state"] == "failed" and "Egeria unreachable" in by["publish_asset"]["detail"] + assert by["classifications"]["state"] == "failed" and "no asset" in by["classifications"]["detail"] + assert by["sub_resources"]["state"] == "failed" and "no parent asset" in by["sub_resources"]["detail"] + assert out["state"] == "failed" + + def test_no_project_context_stops_before_egeria_and_names_the_remedy(self, registry, monkeypatch): + from resource_explorer.workflows import curate_commit as wf + rec = Curations(registry).create("repo", "p", author="a", selection={}, manifest={}, steps=list(wf.STEPS)) + out = wf.execute_curation(registry, rec["id"]) + assert "no Egeria Project context" in out["steps"][0]["detail"] + + +class TestTheDistributionParser: + def test_it_reads_what_the_manifests_declare(self, tmp_path): + from resource_explorer.ingestion.distribution_parser import DistributionParser + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "pyegeria"\nversion = "1.0"\n[project.scripts]\nhey = "x:main"\n[tool.setuptools]\npackages = ["pyegeria"]\n') + (tmp_path / ".github" / "workflows").mkdir(parents=True) + (tmp_path / ".github" / "workflows" / "release.yml").write_text("uses: pypa/gh-action-pypi-publish@release/v1\n") + (tmp_path / "ui").mkdir() + (tmp_path / "ui" / "package.json").write_text('{"name": "@odpi/ui", "bin": {"ui": "cli.js"}}') + (tmp_path / "node_modules" / "left-pad").mkdir(parents=True) + (tmp_path / "node_modules" / "left-pad" / "package.json").write_text('{"name": "left-pad"}') + found = {f["check_name"]: f for f in DistributionParser().parse(tmp_path)} + assert set(found) == {"python:pyegeria", "javascript:@odpi/ui"} # the vendored one is not this repo's + py = found["python:pyegeria"] + assert py["label"] == "published" and py["detail"]["publish_workflow"] == "release.yml" + assert py["detail"]["scripts"] == ["hey"] and py["detail"]["packages"] == ["pyegeria"] + assert found["javascript:@odpi/ui"]["label"] == "declared" + assert "1 command-line entry point(s): hey" in py["summary"] diff --git a/packages/resource-explorer/tests/test_manifest_parse_surveyor.py b/packages/resource-explorer/tests/test_manifest_parse_surveyor.py index d6e88d72b..ee4ae3822 100644 --- a/packages/resource-explorer/tests/test_manifest_parse_surveyor.py +++ b/packages/resource-explorer/tests/test_manifest_parse_surveyor.py @@ -71,7 +71,7 @@ def test_dependencies_ci_and_conventions_all_persisted(self, tmp_path, registry, _write_manifest_repo(tmp_path) anns = ManifestParseSurveyor(project, registry, local_path=str(tmp_path)).run() - assert len(anns) == 4 # dependencies, ci_quality, conventions, supply_chain + assert len(anns) == 5 # dependencies, ci_quality, conventions, supply_chain, distribution assert all(a.analysis_step == STEP for a in anns) deps = registry.query_dependencies(project.slug) @@ -115,7 +115,7 @@ def test_dependency_parser_raising_still_writes_ci_and_conventions(self, tmp_pat ): anns = ManifestParseSurveyor(project, registry, local_path=str(tmp_path)).run() - assert len(anns) == 4 # dependencies, ci_quality, conventions, supply_chain + assert len(anns) == 5 # dependencies, ci_quality, conventions, supply_chain, distribution dep_ann = _by_summary_fragment(anns, "dependency parse failed") assert dep_ann.confidence == 0 assert "boom" in dep_ann.explanation @@ -135,7 +135,7 @@ def test_ci_workflow_parser_raising_still_writes_dependencies_and_conventions( ): anns = ManifestParseSurveyor(project, registry, local_path=str(tmp_path)).run() - assert len(anns) == 4 # dependencies, ci_quality, conventions, supply_chain + assert len(anns) == 5 # dependencies, ci_quality, conventions, supply_chain, distribution ci_ann = _by_summary_fragment(anns, "ci workflow parse failed") assert ci_ann.confidence == 0 @@ -154,7 +154,7 @@ def test_supply_chain_parser_raising_still_writes_the_other_three( ): anns = ManifestParseSurveyor(project, registry, local_path=str(tmp_path)).run() - assert len(anns) == 4 + assert len(anns) == 5 # + distribution (2026-09-12) sc_ann = _by_summary_fragment(anns, "supply-chain parse failed") assert sc_ann.confidence == 0 and "boom" in sc_ann.explanation assert registry.query_dependencies(project.slug) @@ -189,7 +189,7 @@ def test_repo_conventions_parser_raising_still_writes_dependencies_and_ci( ): anns = ManifestParseSurveyor(project, registry, local_path=str(tmp_path)).run() - assert len(anns) == 4 # dependencies, ci_quality, conventions, supply_chain + assert len(anns) == 5 # dependencies, ci_quality, conventions, supply_chain, distribution conv_ann = _by_summary_fragment(anns, "repo conventions parse failed") assert conv_ann.confidence == 0 From e4abc66b062e644b0baa15e6c80980de6c229a9e Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sat, 12 Sep 2026 16:40:33 -0500 Subject: [PATCH 33/37] RE /next: the Curate pane -- review and commit Repo Handoff item 6. One screen, three columns, one commit. What it is (confirmed row by row, a question mark on the Dockerfiles, the implied/no-contract clause on the endpoints), what's in it and what it's made of (counts, each opening its members), how it relates, then the manifest of what gets written -- testimony copied, measurements linked, unresolved things travel -- and the sentence about reversal. The population rule is said on the pane and the button gated, not hidden; anonymous sees why it cannot press. Catalogue -> is a queued run: the record renders under the button and redraws per step as the poll ticks. The sub-resource count opened an empty rail: the members fallback read findings under the analysis id, and they are stored as repo_sub_resource_survey. Mapped, pinned. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- .../resource_explorer/members.py | 8 +- .../resource_explorer/web/static/next/app.js | 185 +++++++++++++++++- .../web/static/next/tailwind-next.css | 2 +- .../resource_explorer/web/static/re-api.js | 14 ++ .../resource-explorer/tests/test_curate.py | 14 ++ .../tests/test_next_curate_pane.py | 45 +++++ 6 files changed, 264 insertions(+), 4 deletions(-) create mode 100644 packages/resource-explorer/tests/test_next_curate_pane.py diff --git a/packages/resource-explorer/resource_explorer/members.py b/packages/resource-explorer/resource_explorer/members.py index 90e59d6a4..1db4bc1f4 100644 --- a/packages/resource-explorer/resource_explorer/members.py +++ b/packages/resource-explorer/resource_explorer/members.py @@ -207,10 +207,16 @@ def _component_members(registry, slug, scope, limit) -> MemberSet: source="architecture_materialized_components") +#: Analyses whose findings are stored under a different kind than their +#: id. The fallback reader looked up `sub_resource_survey` and found nothing +#: (found 2026-09-12 from the Curate screen's "review 31 ›"). +_FINDINGS_KIND = {"sub_resource_survey": "repo_sub_resource_survey"} + + def _findings_members(registry, slug, analysis_id, scope, limit) -> MemberSet: """The fallback: a finding row per member, grouped by verdict, with the detail the display readers drop.""" - rows = [_row(r) for r in registry.query_findings(slug, analysis_id)] + rows = [_row(r) for r in registry.query_findings(slug, _FINDINGS_KIND.get(analysis_id, analysis_id))] by_label: dict[str, list] = {} for r in rows: by_label.setdefault(str(r.get("label") or ""), []).append(r) diff --git a/packages/resource-explorer/resource_explorer/web/static/next/app.js b/packages/resource-explorer/resource_explorer/web/static/next/app.js index 5d62878db..b67c981cf 100644 --- a/packages/resource-explorer/resource_explorer/web/static/next/app.js +++ b/packages/resource-explorer/resource_explorer/web/static/next/app.js @@ -47,6 +47,9 @@ import { getBulkFacts, getMemberChildren, getMembers, + getCuratePlan, + curateCommit, + getCuration, promoteMembers, getQuestions, getScoutingOverview, @@ -4788,11 +4791,14 @@ async function loadPane() { renderPerspectiveRow(); const rows = $('question-rows'); + // Curate's screen is a review-and-commit, not a question list; it renders + // whether or not the catalog has rows for the stage (today it has none). + if (state.stage === 'curate') renderCurate(slug); if (!state.questions.length) { - rows.innerHTML = `
    + rows.innerHTML = state.stage === 'curate' ? '' : `
    No catalogued questions match this stage and this perspective set. That is a fact about the filter, not about the repository.
    `; - $('answered-count').textContent = `0 questions · ${stageLabel()}`; + $('answered-count').textContent = state.stage === 'curate' ? 'review and commit · Curate' : `0 questions · ${stageLabel()}`; return; } @@ -5113,6 +5119,181 @@ function renderEnrichmentEvidence(slug) {
    Material to read, not answers to accept. No "apply suggestion".
    `; } + + +/* ════════════════════════════════════════════════════════════════════════ + * Curate — review and commit + * + * One decision, one screen, one commit — and the commit has consequences in + * two directions, so the screen's whole job is to show both before the + * press. Three columns answer one question, "what should the catalogue know + * about this?": what it IS, what it HOLDS, what it is MADE OF. Then how it + * relates, then the manifest of what gets written. Almost everything on it + * was decided earlier; this is where it is seen assembled. + * + * Rules held (Enrichment and Curate Wireframes; Repo Handoff item 6): + * - Candidates with evidence, never auto-applied: every row names its + * analysis, its state, and opens its members. "Infrastructure Asset? 15 + * Dockerfiles", with the question mark. + * - Testimony is copied, measurements are linked, unresolved things travel. + * - Only worthy things get curated: the population is `tracking` or `using`, + * and the pane says so rather than hiding the resource or the button. + * - The commit is asynchronous and can fail elsewhere: the record shows each + * step as it lands, and a failed step is a failed step, not a lost act. + * ═══════════════════════════════════════════════════════════════════════ */ + +// `pick` marks the one column whose rows are confirmed one by one; the +// others are counts whose members are reviewed, and the contained set is +// taken whole (the checkbox under the manifest) -- the wireframe's shape. +const CURATE_COLUMNS = [ + { key: 'what_it_is', title: 'what it is', sub: 'each confirmed line becomes an entity in the catalogue', pick: true }, + { key: 'what_it_holds', title: "what's in it", sub: 'each becomes its own asset, related to this one' }, + { key: 'made_of', title: "what it's made of", sub: 'components, with ports and wires derived — review stays on Architecture verdicts' }, + { key: 'relates', title: 'how it relates', sub: '' }, +]; + +function curateRowHtml(r, selected, pick) { + const g = factGlyph(r.state); + const mark = pick && r.candidate + ? `` + : `${r.candidate ? '✓' : '·'}`; + const members = r.members?.analysis_id + ? ` · ` + : ''; + return `
    + ${mark} +
    +
    ${tnum(esc(r.label))}
    +
    + ${g.glyph} ${esc(r.source)} + ${r.evidence ? ` · ${esc(r.evidence)}` : ''}${members}
    +
    +
    `; +} + +function curateWritesHtml(plan, picks, subCount) { + const w = plan.writes || {}; + const cls = w.classifications || []; + const lines = []; + lines.push(`${picks.length} entit${picks.length === 1 ? 'y' : 'ies'}${picks.length ? ` · ${picks.map(esc).join(', ')}` : ''}`); + lines.push(`${subCount} contained asset${subCount === 1 ? '' : 's'} (sub-resources)`); + lines.push(cls.length + ? `${cls.length} authored classification${cls.length === 1 ? '' : 's'} · ${cls.map((c) => + `${esc(c.classification)} · ${esc(c.value)} · ${esc(c.author)}${c.interim ? ' · interim' : ''}${c.review ? ' · flagged for review' : ''}`).join(' · ')}` + : `no authored classifications — nothing set on the Enrichment pane yet`); + lines.push(w.owner?.value + ? `Owner · ${esc(w.owner.value)}${w.owner.interim ? ' · interim' : ''}` + : `Owner · the person who catalogues, as interim`); + lines.push(w.licence ? `Licence · ${esc(w.licence)}` : `Licence · not confirmed on the Enrichment pane`); + lines.push(`${w.survey_reports_linked || 0} survey report${w.survey_reports_linked === 1 ? '' : 's'} already linked, not copied${ + w.last_published_at ? ` · last ${esc(ago(w.last_published_at))}` : ''}${ + w.catalogued ? ` · ${esc(String(w.asset_guid).slice(0, 8))}… is the asset` : ' · no asset yet'}`); + return lines.map((l) => `
    ${l}
    `).join(''); +} + +function curateRecordHtml(rec) { + if (!rec) return ''; + const tone = { done: 'text-state-ok', failed: 'text-state-warn', running: 'text-accent-ink', skipped: 'text-ink-muted', pending: 'text-ink-muted' }; + const glyph = { done: '✓', failed: '✗', running: '◐', skipped: '○', pending: '○' }; + return `
    +
    catalogued by ${esc(rec.author)} · ${esc(ago(rec.requested_at))} + · ${esc(rec.state)}${rec.state === 'running' || rec.state === 'queued' ? ' · runs in the worker, not here' : ''}
    + ${(rec.steps || []).map((st) => `
    + ${glyph[st.state] || '·'} + ${esc(st.name)} + ${esc(st.state)}${st.detail ? ` · ${esc(st.detail)}` : ''}
    `).join('')} +
    `; +} + +async function renderCurate(slug) { + const host = $('enrichment-form'); + if (!host) return; + host.innerHTML = `
    Assembling what the catalogue would learn…
    `; + let plan; + try { + plan = await getCuratePlan(slug); + } catch (err) { + host.innerHTML = `
    The plan could not be read: ${esc(err.message)}
    `; + return; + } + if (slug !== state.selectedSlug) return; + state.curate = state.curate || {}; + const picks = new Set(state.curate.picks || plan.what_it_is.filter((r) => r.candidate && r.state === 'measured' && r.kind !== 'InfrastructureAsset').map((r) => r.kind)); + const subs = plan.what_it_holds.find((r) => r.kind === 'SubResource'); + const subLocators = subs?.detail?.worthy || []; + const latest = (plan.commits || [])[0]; + const me = (state.me && (state.me.user_id || state.me.username || state.me.egeria_user)) || ''; + + const draw = () => { + host.innerHTML = ` +
    + ${esc(plan.technology_type)} · disposition ${esc(plan.disposition)}${ + plan.last_surveyed_at ? ` · surveyed ${esc(ago(plan.last_surveyed_at))}` : ' · never surveyed'} +
    + ${plan.in_population ? '' : `

    Only worthy things get curated. Curate's population is + disposition tracking or using; this one is ${esc(plan.disposition)}. Set its disposition (header, or the Disposition + sub-tab) and this screen commits. Everything below still shows what the catalogue would learn.

    `} + ${CURATE_COLUMNS.map((c) => ` +
    + ${esc(c.title)} + ${c.key === 'what_it_is' ? `${picks.size} of ${plan.what_it_is.filter((r) => r.candidate).length} confirmed` : ''} + ${c.sub ? `${esc(c.sub)}` : ''} +
    + ${(plan[c.key] || []).map((r) => curateRowHtml(r, picks.has(r.kind), !!c.pick)).join('')}`).join('')} +
    + what gets written + testimony copied · measurements linked · unresolved things travel +
    + ${curateWritesHtml(plan, [...picks], state.curate.subs === false ? 0 : subLocators.length)} + +
    What keeps it current: ${esc(plan.keeps_current)}
    +
    On cataloguing, this repository becomes an asset the rest of Egeria can see. Reversing this needs a correction, which stays on the record.
    +
    + + ${!me ? 'sign in to catalogue — the record needs an author' : !plan.in_population ? 'not in Curate’s population' : 'a queued run; each step reports as it lands'} +
    + ${curateRecordHtml(latest)}`; + + host.querySelectorAll('[data-curate-pick]').forEach((c) => c.addEventListener('change', () => { + if (c.checked) picks.add(c.dataset.curatePick); else picks.delete(c.dataset.curatePick); + state.curate.picks = [...picks]; draw(); + })); + host.querySelector('[data-curate-subs]')?.addEventListener('change', (ev) => { state.curate.subs = ev.target.checked; draw(); }); + host.querySelectorAll('[data-curate-members]').forEach((b) => b.addEventListener('click', () => { + openMembers({ slug, analysisId: b.dataset.curateMembers, metric: b.dataset.metric || '', title: b.dataset.curateMembers }); + })); + host.querySelector('[data-curate-go]')?.addEventListener('click', async (ev) => { + const b = ev.currentTarget; b.disabled = true; b.textContent = 'Cataloguing…'; + try { + const out = await curateCommit(slug, { + confirm: [...picks], sub_resources: state.curate.subs === false ? [] : subLocators, data_files: false, + }); + plan.commits = [out.curation, ...(plan.commits || [])]; + draw(); + await pollActivity(out.activity_id, { onTick: async () => { + try { + const rec = await getCuration(slug, out.curation.id); + plan.commits[0] = rec; + const slot = host.querySelector('[data-curate-record]'); + if (slot) slot.outerHTML = curateRecordHtml(rec); + } catch { /* the next tick will */ } + } }); + plan.commits[0] = await getCuration(slug, out.curation.id); + draw(); + } catch (err) { + b.disabled = false; b.textContent = 'Catalogue →'; + const why = err.status === 401 ? 'sign in to catalogue' : err.status === 409 ? err.message : `not catalogued: ${err.message}`; + host.querySelector('[data-curate-go]').insertAdjacentHTML('afterend', `${esc(why)}`); + } + }); + }; + draw(); +} + function rowKey(i) { return `qrow-${i}`; } /* ── Purpose orders; Perspective filters ────────────────────────────────── diff --git a/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css b/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css index da0274226..4c567ea0f 100644 --- a/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css +++ b/packages/resource-explorer/resource_explorer/web/static/next/tailwind-next.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SF Mono,Menlo,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.z-40{z-index:40}.z-50{z-index:50}.z-\[90\]{z-index:90}.m-0{margin:0}.my-s3{margin-top:13.8px;margin-bottom:13.8px}.-ml-\[22px\]{margin-left:-22px}.mb-\[2px\]{margin-bottom:2px}.mb-\[3px\]{margin-bottom:3px}.mb-\[4px\]{margin-bottom:4px}.mb-\[6px\]{margin-bottom:6px}.mb-\[7px\]{margin-bottom:7px}.mb-s1{margin-bottom:4.6px}.mb-s2{margin-bottom:9.2px}.mb-s3{margin-bottom:13.8px}.mb-s4{margin-bottom:18.4px}.ml-\[22px\]{margin-left:22px}.ml-auto{margin-left:auto}.ml-s2{margin-left:9.2px}.ml-s3{margin-left:13.8px}.mr-1{margin-right:.25rem}.mr-s3{margin-right:13.8px}.mt-\[10px\]{margin-top:10px}.mt-\[2px\]{margin-top:2px}.mt-\[3px\]{margin-top:3px}.mt-\[4px\]{margin-top:4px}.mt-\[5px\]{margin-top:5px}.mt-\[6px\]{margin-top:6px}.mt-\[6vh\]{margin-top:6vh}.mt-\[7px\]{margin-top:7px}.mt-s1{margin-top:4.6px}.mt-s2{margin-top:9.2px}.mt-s3{margin-top:13.8px}.mt-s4{margin-top:18.4px}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.\!grid{display:grid!important}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-\[14px\]{height:14px}.h-\[9px\]{height:9px}.h-full{height:100%}.h-px{height:1px}.min-h-0{min-height:0}.min-h-\[320px\]{min-height:320px}.w-\[12ch\]{width:12ch}.w-\[13px\]{width:13px}.w-\[160px\]{width:160px}.w-\[16px\]{width:16px}.w-\[18px\]{width:18px}.w-\[22px\]{width:22px}.w-\[26px\]{width:26px}.w-\[34px\]{width:34px}.w-\[42\%\]{width:42%}.w-\[min\(380px\2c 92vw\)\]{width:min(380px,92vw)}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[60ch\]{max-width:60ch}.max-w-\[640px\]{max-width:640px}.max-w-\[70ch\]{max-width:70ch}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize-y{resize:vertical}.resize{resize:both}.scroll-mt-\[8px\]{scroll-margin-top:8px}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.grid-cols-\[130px_1fr\]{grid-template-columns:130px 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.items-start{align-items:flex-start}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-\[1px\]{gap:1px}.gap-\[2px\]{gap:2px}.gap-\[4px\]{gap:4px}.gap-\[5px\]{gap:5px}.gap-\[6px\]{gap:6px}.gap-\[9px\]{gap:9px}.gap-s1{gap:4.6px}.gap-s2{gap:9.2px}.gap-s3{gap:13.8px}.gap-s4{gap:18.4px}.gap-x-s2{-moz-column-gap:9.2px;column-gap:9.2px}.gap-x-s3{-moz-column-gap:13.8px;column-gap:13.8px}.gap-y-\[2px\]{row-gap:2px}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-\[2px\]{border-radius:2px}.rounded-pill{border-radius:11px}.rounded-sm{border-radius:2px}.border{border-width:1px}.border-0{border-width:0}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l{border-left-width:1px}.border-l-2{border-left-width:2px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-accent{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.border-accent-deep{--tw-border-opacity:1;border-color:rgb(90 59 10/var(--tw-border-opacity,1))}.border-chrome-line{--tw-border-opacity:1;border-color:rgb(68 65 65/var(--tw-border-opacity,1))}.border-chrome-line-soft{--tw-border-opacity:1;border-color:rgb(45 43 43/var(--tw-border-opacity,1))}.border-chrome-muted{--tw-border-opacity:1;border-color:rgb(155 151 151/var(--tw-border-opacity,1))}.border-current{border-color:currentColor}.border-rule{border-color:rgba(32,31,29,.16)}.border-rule-strong{--tw-border-opacity:1;border-color:rgb(142 138 138/var(--tw-border-opacity,1))}.border-state-gap{--tw-border-opacity:1;border-color:rgb(91 74 156/var(--tw-border-opacity,1))}.border-state-warn{--tw-border-opacity:1;border-color:rgb(156 66 18/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.bg-\[rgba\(168\2c 113\2c 42\2c \.07\)\]{background-color:rgba(168,113,42,.07)}.bg-\[rgba\(32\2c 31\2c 29\2c \.03\)\]{background-color:rgba(32,31,29,.03)}.bg-black\/40{background-color:rgba(0,0,0,.4)}.bg-chrome{--tw-bg-opacity:1;background-color:rgb(27 26 25/var(--tw-bg-opacity,1))}.bg-chrome-alt{--tw-bg-opacity:1;background-color:rgb(32 31 29/var(--tw-bg-opacity,1))}.bg-chrome-surface{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.bg-paper{--tw-bg-opacity:1;background-color:rgb(243 242 242/var(--tw-bg-opacity,1))}.bg-paper-surface{--tw-bg-opacity:1;background-color:rgb(234 233 233/var(--tw-bg-opacity,1))}.bg-rule{background-color:rgba(32,31,29,.16)}.bg-transparent{background-color:transparent}.p-0{padding:0}.p-\[6px\]{padding:6px}.p-s2{padding:9.2px}.p-s3{padding:13.8px}.p-s4{padding:18.4px}.px-0{padding-left:0;padding-right:0}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-\[10px\]{padding-left:10px;padding-right:10px}.px-\[1px\]{padding-left:1px;padding-right:1px}.px-\[4px\]{padding-left:4px;padding-right:4px}.px-\[5px\]{padding-left:5px;padding-right:5px}.px-\[6px\]{padding-left:6px;padding-right:6px}.px-\[9px\]{padding-left:9px;padding-right:9px}.px-s2{padding-left:9.2px;padding-right:9.2px}.px-s3{padding-left:13.8px;padding-right:13.8px}.py-\[10px\]{padding-top:10px;padding-bottom:10px}.py-\[1px\]{padding-top:1px;padding-bottom:1px}.py-\[2px\]{padding-top:2px;padding-bottom:2px}.py-\[3px\]{padding-top:3px;padding-bottom:3px}.py-\[4px\]{padding-top:4px;padding-bottom:4px}.py-\[5px\]{padding-top:5px;padding-bottom:5px}.py-\[6px\]{padding-top:6px;padding-bottom:6px}.py-\[7px\]{padding-top:7px;padding-bottom:7px}.py-\[9px\]{padding-top:9px;padding-bottom:9px}.py-s2{padding-top:9.2px;padding-bottom:9.2px}.py-s3{padding-top:13.8px;padding-bottom:13.8px}.py-s4{padding-top:18.4px;padding-bottom:18.4px}.pb-\[1px\]{padding-bottom:1px}.pb-\[2px\]{padding-bottom:2px}.pb-\[4px\]{padding-bottom:4px}.pb-s1{padding-bottom:4.6px}.pb-s2{padding-bottom:9.2px}.pb-s3{padding-bottom:13.8px}.pl-0{padding-left:0}.pl-\[20px\]{padding-left:20px}.pl-\[22px\]{padding-left:22px}.pl-s2{padding-left:9.2px}.pl-s3{padding-left:13.8px}.pr-s3{padding-right:13.8px}.pt-\[9px\]{padding-top:9px}.pt-s1{padding-top:4.6px}.pt-s2{padding-top:9.2px}.pt-s3{padding-top:13.8px}.text-left{text-align:left}.text-right{text-align:right}.align-bottom{vertical-align:bottom}.align-\[-2px\]{vertical-align:-2px}.font-body{font-family:Lora,Georgia,serif}.font-diagram{font-family:ui-monospace,SF Mono,Menlo,monospace}.font-heading{font-family:Cormorant Garamond,Georgia,serif}.font-mono{font-family:ui-monospace,SF Mono,Menlo,monospace}.text-\[1\.1rem\]{font-size:1.1rem}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.text-\[17px\]{font-size:17px}.text-answer{font-size:.9375rem;line-height:1.55}.text-brand{font-size:1.1875rem;line-height:1.2}.text-caps{font-size:.75rem;line-height:1.3}.text-caveat{font-size:.9375rem;line-height:1.55}.text-chip{font-size:.8125rem;line-height:1.3}.text-intent{font-size:.875rem;line-height:1.2}.text-name{font-size:1.625rem;line-height:1.15}.text-provenance{font-size:.8125rem;line-height:1.45}.text-question{font-size:1.0625rem;line-height:1.25}.text-resource{font-size:.8125rem;line-height:1.35}.text-subtab{font-size:.875rem;line-height:1.2}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.leading-\[1\.95\]{line-height:1.95}.leading-none{line-height:1}.tracking-caps{letter-spacing:.09em}.tracking-normal{letter-spacing:0}.text-accent{--tw-text-opacity:1;color:rgb(182 130 53/var(--tw-text-opacity,1))}.text-accent-ink{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.text-accent-on-dark{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.text-chrome-ink{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.text-chrome-muted{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.text-ink{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.text-ink-muted{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.text-rule-strong{--tw-text-opacity:1;color:rgb(142 138 138/var(--tw-text-opacity,1))}.text-state-gap{--tw-text-opacity:1;color:rgb(91 74 156/var(--tw-text-opacity,1))}.text-state-gap-on-dark{--tw-text-opacity:1;color:rgb(169 154 224/var(--tw-text-opacity,1))}.text-state-ok{--tw-text-opacity:1;color:rgb(29 107 63/var(--tw-text-opacity,1))}.text-state-ok-on-dark{--tw-text-opacity:1;color:rgb(116 198 141/var(--tw-text-opacity,1))}.text-state-warn{--tw-text-opacity:1;color:rgb(156 66 18/var(--tw-text-opacity,1))}.text-state-warn-on-dark{--tw-text-opacity:1;color:rgb(242 162 106/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-dotted{text-decoration-style:dotted}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.accent-accent{accent-color:#b68235}.opacity-100{opacity:1}.shadow-lg{--tw-shadow:0 12px 32px rgba(45,43,43,.22);--tw-shadow-colored:0 12px 32px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.placeholder\:text-chrome-muted::-moz-placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.placeholder\:text-chrome-muted::placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.placeholder\:text-ink-muted::-moz-placeholder{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.placeholder\:text-ink-muted::placeholder{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.last\:border-0:last-child{border-width:0}.hover\:border-accent:hover{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.hover\:bg-\[rgba\(32\2c 31\2c 29\2c \.05\)\]:hover{background-color:rgba(32,31,29,.05)}.hover\:bg-accent-tint:hover{--tw-bg-opacity:1;background-color:rgb(255 243 228/var(--tw-bg-opacity,1))}.hover\:bg-chrome-surface:hover{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.hover\:text-accent-ink:hover{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.hover\:text-accent-on-dark:hover{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.hover\:text-chrome-ink:hover{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.hover\:text-ink:hover{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.focus-visible\:text-chrome-ink:focus-visible{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SF Mono,Menlo,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.z-40{z-index:40}.z-50{z-index:50}.z-\[90\]{z-index:90}.m-0{margin:0}.my-s3{margin-top:13.8px;margin-bottom:13.8px}.-ml-\[22px\]{margin-left:-22px}.mb-\[2px\]{margin-bottom:2px}.mb-\[3px\]{margin-bottom:3px}.mb-\[4px\]{margin-bottom:4px}.mb-\[6px\]{margin-bottom:6px}.mb-\[7px\]{margin-bottom:7px}.mb-s1{margin-bottom:4.6px}.mb-s2{margin-bottom:9.2px}.mb-s3{margin-bottom:13.8px}.mb-s4{margin-bottom:18.4px}.ml-\[22px\]{margin-left:22px}.ml-auto{margin-left:auto}.ml-s2{margin-left:9.2px}.ml-s3{margin-left:13.8px}.mr-1{margin-right:.25rem}.mr-s3{margin-right:13.8px}.mt-\[10px\]{margin-top:10px}.mt-\[2px\]{margin-top:2px}.mt-\[3px\]{margin-top:3px}.mt-\[4px\]{margin-top:4px}.mt-\[5px\]{margin-top:5px}.mt-\[6px\]{margin-top:6px}.mt-\[6vh\]{margin-top:6vh}.mt-\[7px\]{margin-top:7px}.mt-s1{margin-top:4.6px}.mt-s2{margin-top:9.2px}.mt-s3{margin-top:13.8px}.mt-s4{margin-top:18.4px}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.\!grid{display:grid!important}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-\[14px\]{height:14px}.h-\[9px\]{height:9px}.h-full{height:100%}.h-px{height:1px}.min-h-0{min-height:0}.min-h-\[320px\]{min-height:320px}.w-\[12ch\]{width:12ch}.w-\[13px\]{width:13px}.w-\[160px\]{width:160px}.w-\[16px\]{width:16px}.w-\[18px\]{width:18px}.w-\[22px\]{width:22px}.w-\[26px\]{width:26px}.w-\[34px\]{width:34px}.w-\[42\%\]{width:42%}.w-\[min\(380px\2c 92vw\)\]{width:min(380px,92vw)}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[60ch\]{max-width:60ch}.max-w-\[640px\]{max-width:640px}.max-w-\[70ch\]{max-width:70ch}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize-y{resize:vertical}.resize{resize:both}.scroll-mt-\[8px\]{scroll-margin-top:8px}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.grid-cols-\[130px_1fr\]{grid-template-columns:130px 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.items-start{align-items:flex-start}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-\[1px\]{gap:1px}.gap-\[2px\]{gap:2px}.gap-\[4px\]{gap:4px}.gap-\[5px\]{gap:5px}.gap-\[6px\]{gap:6px}.gap-\[9px\]{gap:9px}.gap-s1{gap:4.6px}.gap-s2{gap:9.2px}.gap-s3{gap:13.8px}.gap-s4{gap:18.4px}.gap-x-s2{-moz-column-gap:9.2px;column-gap:9.2px}.gap-x-s3{-moz-column-gap:13.8px;column-gap:13.8px}.gap-y-\[2px\]{row-gap:2px}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-\[2px\]{border-radius:2px}.rounded-pill{border-radius:11px}.rounded-sm{border-radius:2px}.border{border-width:1px}.border-0{border-width:0}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l{border-left-width:1px}.border-l-2{border-left-width:2px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-accent{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.border-accent-deep{--tw-border-opacity:1;border-color:rgb(90 59 10/var(--tw-border-opacity,1))}.border-chrome-line{--tw-border-opacity:1;border-color:rgb(68 65 65/var(--tw-border-opacity,1))}.border-chrome-line-soft{--tw-border-opacity:1;border-color:rgb(45 43 43/var(--tw-border-opacity,1))}.border-chrome-muted{--tw-border-opacity:1;border-color:rgb(155 151 151/var(--tw-border-opacity,1))}.border-current{border-color:currentColor}.border-rule{border-color:rgba(32,31,29,.16)}.border-rule-strong{--tw-border-opacity:1;border-color:rgb(142 138 138/var(--tw-border-opacity,1))}.border-state-gap{--tw-border-opacity:1;border-color:rgb(91 74 156/var(--tw-border-opacity,1))}.border-state-warn{--tw-border-opacity:1;border-color:rgb(156 66 18/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.bg-\[rgba\(168\2c 113\2c 42\2c \.07\)\]{background-color:rgba(168,113,42,.07)}.bg-\[rgba\(32\2c 31\2c 29\2c \.03\)\]{background-color:rgba(32,31,29,.03)}.bg-black\/40{background-color:rgba(0,0,0,.4)}.bg-chrome{--tw-bg-opacity:1;background-color:rgb(27 26 25/var(--tw-bg-opacity,1))}.bg-chrome-alt{--tw-bg-opacity:1;background-color:rgb(32 31 29/var(--tw-bg-opacity,1))}.bg-chrome-surface{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.bg-paper{--tw-bg-opacity:1;background-color:rgb(243 242 242/var(--tw-bg-opacity,1))}.bg-paper-surface{--tw-bg-opacity:1;background-color:rgb(234 233 233/var(--tw-bg-opacity,1))}.bg-rule{background-color:rgba(32,31,29,.16)}.bg-transparent{background-color:transparent}.p-0{padding:0}.p-\[6px\]{padding:6px}.p-s2{padding:9.2px}.p-s3{padding:13.8px}.p-s4{padding:18.4px}.px-0{padding-left:0;padding-right:0}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-\[10px\]{padding-left:10px;padding-right:10px}.px-\[1px\]{padding-left:1px;padding-right:1px}.px-\[4px\]{padding-left:4px;padding-right:4px}.px-\[5px\]{padding-left:5px;padding-right:5px}.px-\[6px\]{padding-left:6px;padding-right:6px}.px-\[9px\]{padding-left:9px;padding-right:9px}.px-s2{padding-left:9.2px;padding-right:9.2px}.px-s3{padding-left:13.8px;padding-right:13.8px}.py-\[10px\]{padding-top:10px;padding-bottom:10px}.py-\[1px\]{padding-top:1px;padding-bottom:1px}.py-\[2px\]{padding-top:2px;padding-bottom:2px}.py-\[3px\]{padding-top:3px;padding-bottom:3px}.py-\[4px\]{padding-top:4px;padding-bottom:4px}.py-\[5px\]{padding-top:5px;padding-bottom:5px}.py-\[6px\]{padding-top:6px;padding-bottom:6px}.py-\[7px\]{padding-top:7px;padding-bottom:7px}.py-\[9px\]{padding-top:9px;padding-bottom:9px}.py-s2{padding-top:9.2px;padding-bottom:9.2px}.py-s3{padding-top:13.8px;padding-bottom:13.8px}.py-s4{padding-top:18.4px;padding-bottom:18.4px}.pb-\[1px\]{padding-bottom:1px}.pb-\[2px\]{padding-bottom:2px}.pb-\[4px\]{padding-bottom:4px}.pb-s1{padding-bottom:4.6px}.pb-s2{padding-bottom:9.2px}.pb-s3{padding-bottom:13.8px}.pl-0{padding-left:0}.pl-\[20px\]{padding-left:20px}.pl-\[22px\]{padding-left:22px}.pl-s2{padding-left:9.2px}.pl-s3{padding-left:13.8px}.pr-s3{padding-right:13.8px}.pt-\[9px\]{padding-top:9px}.pt-s1{padding-top:4.6px}.pt-s2{padding-top:9.2px}.pt-s3{padding-top:13.8px}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-bottom{vertical-align:bottom}.align-\[-2px\]{vertical-align:-2px}.font-body{font-family:Lora,Georgia,serif}.font-diagram{font-family:ui-monospace,SF Mono,Menlo,monospace}.font-heading{font-family:Cormorant Garamond,Georgia,serif}.font-mono{font-family:ui-monospace,SF Mono,Menlo,monospace}.text-\[1\.1rem\]{font-size:1.1rem}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.text-\[17px\]{font-size:17px}.text-answer{font-size:.9375rem;line-height:1.55}.text-brand{font-size:1.1875rem;line-height:1.2}.text-caps{font-size:.75rem;line-height:1.3}.text-caveat{font-size:.9375rem;line-height:1.55}.text-chip{font-size:.8125rem;line-height:1.3}.text-intent{font-size:.875rem;line-height:1.2}.text-name{font-size:1.625rem;line-height:1.15}.text-provenance{font-size:.8125rem;line-height:1.45}.text-question{font-size:1.0625rem;line-height:1.25}.text-resource{font-size:.8125rem;line-height:1.35}.text-subtab{font-size:.875rem;line-height:1.2}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.leading-\[1\.95\]{line-height:1.95}.leading-none{line-height:1}.tracking-caps{letter-spacing:.09em}.tracking-normal{letter-spacing:0}.text-accent{--tw-text-opacity:1;color:rgb(182 130 53/var(--tw-text-opacity,1))}.text-accent-ink{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.text-accent-on-dark{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.text-chrome-ink{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.text-chrome-muted{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.text-ink{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.text-ink-muted{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.text-rule-strong{--tw-text-opacity:1;color:rgb(142 138 138/var(--tw-text-opacity,1))}.text-state-gap{--tw-text-opacity:1;color:rgb(91 74 156/var(--tw-text-opacity,1))}.text-state-gap-on-dark{--tw-text-opacity:1;color:rgb(169 154 224/var(--tw-text-opacity,1))}.text-state-ok{--tw-text-opacity:1;color:rgb(29 107 63/var(--tw-text-opacity,1))}.text-state-ok-on-dark{--tw-text-opacity:1;color:rgb(116 198 141/var(--tw-text-opacity,1))}.text-state-warn{--tw-text-opacity:1;color:rgb(156 66 18/var(--tw-text-opacity,1))}.text-state-warn-on-dark{--tw-text-opacity:1;color:rgb(242 162 106/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-dotted{text-decoration-style:dotted}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.accent-accent{accent-color:#b68235}.opacity-100{opacity:1}.opacity-60{opacity:.6}.shadow-lg{--tw-shadow:0 12px 32px rgba(45,43,43,.22);--tw-shadow-colored:0 12px 32px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.placeholder\:text-chrome-muted::-moz-placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.placeholder\:text-chrome-muted::placeholder{--tw-text-opacity:1;color:rgb(155 151 151/var(--tw-text-opacity,1))}.placeholder\:text-ink-muted::-moz-placeholder{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.placeholder\:text-ink-muted::placeholder{--tw-text-opacity:1;color:rgb(74 70 62/var(--tw-text-opacity,1))}.last\:border-0:last-child{border-width:0}.hover\:border-accent:hover{--tw-border-opacity:1;border-color:rgb(182 130 53/var(--tw-border-opacity,1))}.hover\:bg-\[rgba\(32\2c 31\2c 29\2c \.05\)\]:hover{background-color:rgba(32,31,29,.05)}.hover\:bg-accent-tint:hover{--tw-bg-opacity:1;background-color:rgb(255 243 228/var(--tw-bg-opacity,1))}.hover\:bg-chrome-surface:hover{--tw-bg-opacity:1;background-color:rgb(45 43 43/var(--tw-bg-opacity,1))}.hover\:text-accent-ink:hover{--tw-text-opacity:1;color:rgb(125 84 17/var(--tw-text-opacity,1))}.hover\:text-accent-on-dark:hover{--tw-text-opacity:1;color:rgb(225 173 102/var(--tw-text-opacity,1))}.hover\:text-chrome-ink:hover{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))}.hover\:text-ink:hover{--tw-text-opacity:1;color:rgb(32 31 29/var(--tw-text-opacity,1))}.focus-visible\:text-chrome-ink:focus-visible{--tw-text-opacity:1;color:rgb(243 242 242/var(--tw-text-opacity,1))} \ No newline at end of file diff --git a/packages/resource-explorer/resource_explorer/web/static/re-api.js b/packages/resource-explorer/resource_explorer/web/static/re-api.js index f50c0fb87..121ec2cbe 100644 --- a/packages/resource-explorer/resource_explorer/web/static/re-api.js +++ b/packages/resource-explorer/resource_explorer/web/static/re-api.js @@ -587,3 +587,17 @@ export async function pollActivity(entryId, { /* ── Investigations ──────────────────────────────────────────────────── */ export const listInvestigations = () => get('/api/investigations/'); + +/* ── Curate ─────────────────────────────────────────────────────────────── */ + +/** The review-and-commit plan: three columns, the manifest, the record. A + * local read on the server, so it renders when Egeria is down. */ +export const getCuratePlan = (slug) => get(`/api/projects/${encodeURIComponent(slug)}/curate/plan`); + +/** Catalogue →. Returns {curation, activity_id, run_id}; 401 anonymous, + * 409 outside the population. */ +export const curateCommit = (slug, selection) => + post(`/api/projects/${encodeURIComponent(slug)}/curate/commit`, selection); + +export const getCuration = (slug, id) => + get(`/api/projects/${encodeURIComponent(slug)}/curate/commits/${encodeURIComponent(id)}`); diff --git a/packages/resource-explorer/tests/test_curate.py b/packages/resource-explorer/tests/test_curate.py index d659fbf3d..0ff865723 100644 --- a/packages/resource-explorer/tests/test_curate.py +++ b/packages/resource-explorer/tests/test_curate.py @@ -248,3 +248,17 @@ def test_it_reads_what_the_manifests_declare(self, tmp_path): assert py["detail"]["scripts"] == ["hey"] and py["detail"]["packages"] == ["pyegeria"] assert found["javascript:@odpi/ui"]["label"] == "declared" assert "1 command-line entry point(s): hey" in py["summary"] + + +class TestTheMembersBehindTheCounts: + """Every count on the Curate screen is a link to its members (the design: + "you cannot confirm ninety contained datasets without reading them"). + The sub-resource count opened an empty rail because the findings are + stored as `repo_sub_resource_survey`, not `sub_resource_survey`.""" + + def test_the_sub_resource_count_opens_its_members(self, registry): + _seed(registry) + from resource_explorer.members import members_for + ms = members_for(registry, "p", "sub_resource_survey", scope="all") + assert ms.total == 3 + assert {g.name: g.count for g in ms.groups} == {"worthy": 2, "not_worthy": 1} diff --git a/packages/resource-explorer/tests/test_next_curate_pane.py b/packages/resource-explorer/tests/test_next_curate_pane.py new file mode 100644 index 000000000..cc977d282 --- /dev/null +++ b/packages/resource-explorer/tests/test_next_curate_pane.py @@ -0,0 +1,45 @@ +"""The Curate pane in /next: what a Python suite can hold it to without a +browser. The screen itself was verified on a branch server (2026-09-12).""" +from __future__ import annotations + +import re +from pathlib import Path + +NEXT = Path(__file__).resolve().parents[1] / "resource_explorer" / "web" / "static" / "next" + + +def _app(): + return (NEXT / "app.js").read_text(encoding="utf-8") + + +class TestTheScreenHoldsTheDesignRules: + def test_it_renders_whether_or_not_the_catalog_has_rows_for_the_stage(self): + app = _app() + i = app.index("if (state.stage === 'curate') renderCurate(slug);") + j = app.index("if (!state.questions.length) {", i) + assert i < j, "Curate must render before the empty-question early return" + + def test_only_what_it_is_is_confirmed_row_by_row(self): + app = _app() + cols = app[app.index("const CURATE_COLUMNS = ["):app.index("];", app.index("const CURATE_COLUMNS = ["))] + assert cols.count("pick: true") == 1 and "key: 'what_it_is'" in cols.split("pick: true")[0].rsplit("{", 1)[-1] + + def test_the_population_rule_is_said_and_the_button_is_gated_not_hidden(self): + app = _app() + body = app[app.index("async function renderCurate("):app.index("function rowKey(i)")] + assert "Only worthy things get curated" in body + assert "data-curate-go ${plan.in_population && me ? '' : 'disabled'}" in body + assert "sign in to catalogue" in body + + def test_every_count_opens_its_members_and_the_manifest_names_the_three_rules(self): + app = _app() + body = app[app.index("function curateRowHtml("):app.index("function rowKey(i)")] + assert "data-curate-members" in body and "openMembers({ slug, analysisId: b.dataset.curateMembers" in body + assert "testimony copied · measurements linked · unresolved things travel" in body + assert "Reversing this needs a correction, which stays on the record." in body + + def test_the_commit_is_polled_and_the_record_redrawn_per_step(self): + app = _app() + body = app[app.index("async function renderCurate("):app.index("function rowKey(i)")] + assert "await pollActivity(out.activity_id" in body and "getCuration(slug, out.curation.id)" in body + assert "runs in the worker, not here" in app From e727b2f80410468d5693c06dd5c0cd932dc3b0d7 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sat, 12 Sep 2026 16:50:38 -0500 Subject: [PATCH 34/37] RE tests: the distribution sub-parse's two return paths are exclusive, like its siblings' Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- packages/resource-explorer/tests/test_annotation_check_names.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/resource-explorer/tests/test_annotation_check_names.py b/packages/resource-explorer/tests/test_annotation_check_names.py index b6d163d61..fff317afb 100644 --- a/packages/resource-explorer/tests/test_annotation_check_names.py +++ b/packages/resource-explorer/tests/test_annotation_check_names.py @@ -78,6 +78,8 @@ ("sub_surveyors/manifest_parse.py", "dependency_manifest"), ("sub_surveyors/manifest_parse.py", "repo_conventions_manifest"), ("sub_surveyors/manifest_parse.py", "supply_chain_manifest"), + # the fifth sub-parse (2026-09-12): success `return` and except `return` + ("sub_surveyors/manifest_parse.py", "distribution_manifest"), ("sub_surveyors/rag_ingestion.py", "rag_ingestion"), ("sub_surveyors/secret_scan.py", "scan_summary"), ("sub_surveyors/security_hygiene.py", "ci_config"), From d3be6f76093d753b640c3300457465556bbe51c2 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sat, 12 Sep 2026 17:21:28 -0500 Subject: [PATCH 35/37] RE Curate: what the first live press taught -- say the slow step is surveying, count against the selection The owner pressed Catalogue -> and reported nothing obvious happening. The first step re-surveys before it publishes (the existing publish path), which is minutes on a large repository, and the button and the record said only 'running'. Both now say surveying first, then publishing, and the record carries a line naming the running step. The sub-resource step reported '32 of 31 published': the count included the ancestor folder NestedFile needs. Counted against the selection now, with the ancestors named separately. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- .../resource_explorer/web/static/next/app.js | 8 +++++++- .../resource_explorer/workflows/curate_commit.py | 10 ++++++++-- packages/resource-explorer/tests/test_curate.py | 1 + 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/resource-explorer/resource_explorer/web/static/next/app.js b/packages/resource-explorer/resource_explorer/web/static/next/app.js index b67c981cf..e550681f2 100644 --- a/packages/resource-explorer/resource_explorer/web/static/next/app.js +++ b/packages/resource-explorer/resource_explorer/web/static/next/app.js @@ -5200,6 +5200,8 @@ function curateRecordHtml(rec) { return `
    catalogued by ${esc(rec.author)} · ${esc(ago(rec.requested_at))} · ${esc(rec.state)}${rec.state === 'running' || rec.state === 'queued' ? ' · runs in the worker, not here' : ''}
    + ${rec.state === 'running' && (rec.steps || []).some((st) => st.state === 'running') ? `
    ◐ ${ + esc((rec.steps.find((st) => st.state === 'running') || {}).name)} is running — the survey step takes minutes; this line updates as steps land.
    ` : ''} ${(rec.steps || []).map((st) => `
    ${glyph[st.state] || '·'} ${esc(st.name)} @@ -5267,7 +5269,11 @@ async function renderCurate(slug) { openMembers({ slug, analysisId: b.dataset.curateMembers, metric: b.dataset.metric || '', title: b.dataset.curateMembers }); })); host.querySelector('[data-curate-go]')?.addEventListener('click', async (ev) => { - const b = ev.currentTarget; b.disabled = true; b.textContent = 'Cataloguing…'; + const b = ev.currentTarget; b.disabled = true; + // The first step re-surveys before it publishes -- minutes on a large + // repository, and "nothing obvious happening" was the owner's report + // from the first live press. Say what is happening, from the record. + b.textContent = 'Cataloguing… surveying first, then publishing'; try { const out = await curateCommit(slug, { confirm: [...picks], sub_resources: state.curate.subs === false ? [] : subLocators, data_files: false, diff --git a/packages/resource-explorer/resource_explorer/workflows/curate_commit.py b/packages/resource-explorer/resource_explorer/workflows/curate_commit.py index ffa732884..887ccb110 100644 --- a/packages/resource-explorer/resource_explorer/workflows/curate_commit.py +++ b/packages/resource-explorer/resource_explorer/workflows/curate_commit.py @@ -87,7 +87,8 @@ def execute_curation(registry: ProjectRegistry, curation_id: str) -> dict: # ── 1. the asset and its survey report ───────────────────────────── asset_guid = "" - cur.set_step(curation_id, "publish_asset", "running") + cur.set_step(curation_id, "publish_asset", "running", + "surveying first, then publishing — minutes on a large repository; the existing publish path") try: context = registry.get_project_context("repo", slug) if not context or context.get("status") == "unset": @@ -171,8 +172,13 @@ def execute_curation(registry: ProjectRegistry, curation_id: str) -> dict: registry.catalog_sub_resource("repo", slug, loc, want[loc], source_finding="repo_sub_resource_survey") guids = EgeriaPublisher(registry=registry).publish_sub_resources(slug, project.github_url, asset_guid, sorted(want)) missing = [l for l in locators if l not in guids] + ancestors = len(want) - len(locators) + # Count against what was SELECTED; the ancestor folders NestedFile + # needs are named separately ("32 of 31" on the first live press). cur.set_step(curation_id, "sub_resources", "failed" if missing else "done", - f"{len(guids)} of {len(locators)} published" + (f" · not published: {', '.join(missing[:8])}" if missing else "")) + f"{len(locators) - len(missing)} of {len(locators)} published" + + (f" · plus {ancestors} ancestor folder{'s' if ancestors != 1 else ''}" if ancestors else "") + + (f" · not published: {', '.join(missing[:8])}" if missing else "")) except Exception as exc: cur.set_step(curation_id, "sub_resources", "failed", f"{type(exc).__name__}: {exc}") diff --git a/packages/resource-explorer/tests/test_curate.py b/packages/resource-explorer/tests/test_curate.py index 0ff865723..670dd742b 100644 --- a/packages/resource-explorer/tests/test_curate.py +++ b/packages/resource-explorer/tests/test_curate.py @@ -196,6 +196,7 @@ def set_confidentiality_classification(self, guid, body): assert by["publish_asset"]["state"] == "done" and "report-9" in by["publish_asset"]["detail"] assert by["classifications"]["state"] == "done" and "Confidentiality · internal" in by["classifications"]["detail"] assert by["sub_resources"]["state"] == "done" + assert by["sub_resources"]["detail"] == "1 of 1 published · plus 1 ancestor folder" # not "2 of 1" assert by["components"]["state"] == "skipped" assert out["state"] == "done" conf = next(c for c in calls if c[0] == "conf") From dce1140b92521656ac37d43eb48dc8bc8f67e1cc Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sat, 12 Sep 2026 17:23:54 -0500 Subject: [PATCH 36/37] =?UTF-8?q?RE:=20dependency=5Fsupport=20=E2=80=94=20?= =?UTF-8?q?one=20item=5Fkey,=20so=20a=20repo=20with=20two=20technologies?= =?UTF-8?q?=20can=20publish=20at=20all?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #46 shipped a publish regression. The step emits one ClassificationAnnotation per matched technology, all check_name "technology", and carried no item_key - so any repo matching two or more technologies produced two identical qualifiedNames and the publisher refused the WHOLE publish before writing: Curate's Catalogue, the survey pane's Publish, and resync, all of them. Found by the /next session on the first live press of Curate on egeria_workspaces_git (2026-09-12 17:13), not by any test. One line: item_key=m.technology.name, the same convention api_structure (language), dependency (ecosystem) and documentation (col_type) already follow for their per-item annotations. Why no test caught it: test_annotation_check_names inspects static emission sites and this is a loop. The new test does what the publisher does - runs the surveyor for real on a fixture matching three technologies and hands the annotations to assert_unique_qualified_names. On the shipped code it raises the exact ValueError seen live; with the fix it passes, and a second test pins that every per-technology annotation carries a distinct item_key. A loop-shaped guard in test_annotation_check_names would have found this statically and is worth adding, but as its own change rather than on a hotfix that blocks a live test. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- .../sub_surveyors/dependency_support.py | 8 +++ .../tests/test_dependency_support.py | 58 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/dependency_support.py b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/dependency_support.py index 4717d560b..bcc5d6078 100644 --- a/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/dependency_support.py +++ b/packages/resource-explorer/resource_explorer/surveyors/sub_surveyors/dependency_support.py @@ -82,6 +82,14 @@ def run(self) -> list[Annotation]: results.append(ClassificationAnnotation( summary=f"{m.technology.name} — indicated by {', '.join(sorted(m.dependencies)[:6])}", analysis_step=STEP, check_name="technology", + # One annotation per matched technology makes this check + # list-shaped, and the publisher builds each qualifiedName + # from (slug, run, check_name, item_key). Without the key, + # any repo with two matched technologies collided and EVERY + # publish of it — Curate, ☁ Publish, resync — was refused + # before writing. Found on the first live press, not by the + # static check-name guard, which sees sites and not loops. + item_key=m.technology.name, candidate_classifications=[m.technology.name], confidence=85 if m.egeria_state == "present" else 70, json_properties={ diff --git a/packages/resource-explorer/tests/test_dependency_support.py b/packages/resource-explorer/tests/test_dependency_support.py index 19150e111..0e3ed80e6 100644 --- a/packages/resource-explorer/tests/test_dependency_support.py +++ b/packages/resource-explorer/tests/test_dependency_support.py @@ -337,3 +337,61 @@ def test_every_linked_type_exists(self): assert not missing, ( f"these mapping entries link to Egeria technology types that do not exist: {missing}. " f"Either the displayName is wrong or the type was removed.") + + +class TestItsAnnotationsPublish: + """The regression #46 shipped: one ClassificationAnnotation per matched + technology, all check_name="technology", no item_key — so any repo with two + or more matched technologies produced two identical qualifiedNames and the + publisher refused the WHOLE publish (Curate, ☁ Publish, resync), before + writing anything. Found on the first live press of Curate → Catalogue on + egeria_workspaces_git, 2026-09-12 17:13, not by any test. + + test_annotation_check_names did not catch it because it inspects static + emission sites, and this is a loop. So this test does what the publisher + does: runs the surveyor for real on a repo that matches ≥2 technologies and + hands its annotations to the publisher's own uniqueness check. + """ + + @pytest.fixture + def slug(self, request): + import re + return "dsp_" + re.sub(r"[^a-z0-9]+", "_", request.node.name.lower())[:44] + + @pytest.fixture + def reg(self, pg_registry, slug): + from resource_explorer.registry import Project + pg_registry.add(Project(slug=slug, display_name=slug, github_url=f"https://github.com/x/{slug}")) + # Three technologies — the collision needs at least two. + pg_registry.upsert_dependencies(slug, [ + {"dep_name": "psycopg2-binary", "dep_version": "2.9", "dep_type": "runtime", + "ecosystem": "python", "source_file": "requirements.txt"}, + {"dep_name": "confluent-kafka", "dep_version": "2.3", "dep_type": "runtime", + "ecosystem": "python", "source_file": "requirements.txt"}, + {"dep_name": "redis", "dep_version": "5.0", "dep_type": "runtime", + "ecosystem": "python", "source_file": "requirements.txt"}, + ]) + return pg_registry + + def _annotations(self, reg, slug, monkeypatch): + from resource_explorer.surveyors.sub_surveyors.dependency_support import DependencySupportSurveyor + monkeypatch.setattr(ds, "egeria_technology_types_present", + lambda names: ({n: True for n in names}, "checked", "stubbed")) + return DependencySupportSurveyor(project=reg.get(slug), registry=reg).run() + + def test_multiple_matched_technologies_publish_under_distinct_qualified_names(self, reg, slug, monkeypatch): + from resource_explorer.surveyors.survey_report import assert_unique_qualified_names + anns = self._annotations(reg, slug, monkeypatch) + techs = [a for a in anns if a.check_name == "technology"] + assert len(techs) >= 2, "the fixture must produce the collision case" + # This is the exact call the publisher makes before writing; it raised + # ValueError on the shipped code. + assert_unique_qualified_names(f"Annotation::{slug}::2026-09-12T00:00:00", anns) + + def test_every_per_technology_annotation_carries_its_own_item_key(self, reg, slug, monkeypatch): + anns = self._annotations(reg, slug, monkeypatch) + techs = [a for a in anns if a.check_name == "technology"] + keys = [a.item_key for a in techs] + assert all(keys), f"a per-technology annotation has no item_key: {keys}" + assert len(set(keys)) == len(keys), f"item_keys are not distinct: {keys}" + assert set(keys) == {"PostgreSQL", "Apache Kafka", "Redis"} From 7433592f9ab7998b777fc11b903985fec1f751d3 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sat, 12 Sep 2026 17:41:29 -0500 Subject: [PATCH 37/37] RE promotion: the review's seven defects and the house rules REVIEW-PROMOTION.md (2026-09-12, read against 4d12173), which sat unread until this evening because it was written to disk while I looked in the design project. '1 advisorie': rstrip('s') strips a character set, not a suffix. A real singular -- ies to y, es after a sibilant, else drop the s -- on the server and mirrored in the shell, and the n=1 path is tested now. A typed name was discarded on the next checkbox because the keep-test was 'does not start with the display name' and the proposal starts with it too. Touched is tracked from the input event; a typed name wins until cleared. Hand-picking erased the facet. The facet is remembered with the set it selected and a refinement is described -- 'high, plus 1 added by hand, less 1 removed by hand' -- on the footer, the proposed name and the provenance line. Verified in the browser through all five moves. The run date came from the browser and was never populated off one path. It is on the members payload from the registry's own bookkeeping, and the route composes the line from the server's date, not the body's. Facet group labels were cut to their first word; whole now. Group chips count the group's own count (the server knows it in full); detail chips say 'counts are of the N shown' when the list is capped. suggest_to is a perspective or a user id, not any string. The journal joins with a middot like everything else here. House rules: no gold fill on the checkboxes; clickable chip labels in ink; a tree-shaped list says 'promotion selects leaves'; a single-value list says 'no facets'; zero groups names the source it looked in. Co-Authored-By: Claude Opus 5 Signed-off-by: Dan Wolfson --- .../resource_explorer/members.py | 36 +++++++- .../resource_explorer/web/routes/projects.py | 16 +++- .../resource_explorer/web/static/next/app.js | 92 +++++++++++++++---- .../tests/test_next_journal_fidelity.py | 37 ++++++++ .../resource-explorer/tests/test_promotion.py | 35 +++++++ 5 files changed, 190 insertions(+), 26 deletions(-) diff --git a/packages/resource-explorer/resource_explorer/members.py b/packages/resource-explorer/resource_explorer/members.py index 1db4bc1f4..1b2b43173 100644 --- a/packages/resource-explorer/resource_explorer/members.py +++ b/packages/resource-explorer/resource_explorer/members.py @@ -52,6 +52,7 @@ class MemberSet: note: str = "" source: str = "" # what was read: a table or the findings inventory: str = "" # the short form: "6,423 files · 4,425 vendored" + run_at: str = "" # when the analysis this set came from last ran -- the server's date, not the browser's def to_dict(self) -> dict: return { @@ -60,6 +61,7 @@ def to_dict(self) -> dict: "groups": [{"name": g.name, "count": g.count, "members": g.members, "truncated": g.truncated} for g in self.groups], "note": self.note, "source": self.source, "inventory": self.inventory, + "run_at": self.run_at, } @@ -242,13 +244,24 @@ def _findings_members(registry, slug, analysis_id, scope, limit) -> MemberSet: } +def last_run_at(registry: ProjectRegistry, slug: str, analysis_id: str) -> str: + """When this analysis last ran, from the registry's own bookkeeping. The + provenance line is composed on the server so it cannot be forged; a line + that took its date from the browser was not (review, 2026-09-12).""" + try: + runs = registry.get_analysis_last_run("repo", slug) or {} + return (runs.get(analysis_id) or {}).get("last_run_at", "") or "" + except Exception: + return "" + + def members_for(registry: ProjectRegistry, slug: str, analysis_id: str, metric: str = "", scope: str = "public", limit: int = DEFAULT_LIMIT) -> MemberSet: scope = "all" if scope == "all" else "public" reader = _READERS.get((analysis_id, metric or None)) or _READERS.get((analysis_id, None)) - if reader is not None: - return reader(registry, slug, scope, limit) - return _findings_members(registry, slug, analysis_id, scope, limit) + ms = reader(registry, slug, scope, limit) if reader is not None else _findings_members(registry, slug, analysis_id, scope, limit) + ms.run_at = last_run_at(registry, slug, analysis_id) + return ms def children_for(registry: ProjectRegistry, slug: str, analysis_id: str, key: str, @@ -299,5 +312,20 @@ def proposed_name(display_name: str, *, total: int, members: list[str], facet: s """'egeria-workspaces — 3 advisories, high'. Editable before saving.""" what = (metric.replace("_", " ") if metric else "members") n = len(members) - core = f"{n} {what}" if n != 1 else f"1 {what.rstrip('s') or what}" + core = f"{n} {what}" if n != 1 else f"1 {singular(what)}" return f"{display_name} — {core}" + (f", {facet}" if facet else "") + + +def singular(noun: str) -> str: + """'advisories' -> 'advisory', 'dependencies' -> 'dependency', 'files' -> + 'file'. The first version was `rstrip('s')`, which strips a character SET + and produced '1 advisorie' (review, 2026-09-12). Only the last word of a + phrase is singularised: 'data files' -> 'data file'.""" + head, _, last = noun.rpartition(" ") + if last.endswith("ies") and len(last) > 3: + last = last[:-3] + "y" + elif last.endswith("ses") or last.endswith("xes") or last.endswith("ches") or last.endswith("shes"): + last = last[:-2] + elif last.endswith("s") and not last.endswith("ss"): + last = last[:-1] + return f"{head} {last}".strip() diff --git a/packages/resource-explorer/resource_explorer/web/routes/projects.py b/packages/resource-explorer/resource_explorer/web/routes/projects.py index e3a6c18a6..ddff20f50 100644 --- a/packages/resource-explorer/resource_explorer/web/routes/projects.py +++ b/packages/resource-explorer/resource_explorer/web/routes/projects.py @@ -3,6 +3,7 @@ import asyncio import json +import re import logging from fastapi import APIRouter, HTTPException, Request @@ -1490,7 +1491,18 @@ def promote_members(slug: str, analysis_id: str, body: PromoteSelection, request if not project: raise HTTPException(status_code=404, detail=f"Project '{slug}' not found") - line = provenance_line(analysis_id=analysis_id, run_at=body.run_at, total=body.total, + # The date is the server's: what the registry says this analysis last ran, + # not what the browser sent (which was never populated off one path). + from resource_explorer.members import last_run_at + run_at = last_run_at(registry, slug, analysis_id) or body.run_at + if body.suggest_to: + # An audience is a perspective or a person; a free string minted a + # work list named suggested-to- for any signed-in caller. + from resource_explorer.surveyors.analysis_catalog_reader import EGERIA_PERSPECTIVES + bad = [t for t in body.suggest_to if t not in EGERIA_PERSPECTIVES and not re.fullmatch(r"[A-Za-z0-9_.@-]{1,64}", t)] + if bad: + raise HTTPException(status_code=400, detail=f"suggest_to must name a perspective or a user id: {bad}") + line = provenance_line(analysis_id=analysis_id, run_at=run_at, total=body.total, members=body.members, facet=body.facet, metric=body.metric) name = body.name.strip() or proposed_name(project.display_name or slug, total=body.total, members=body.members, facet=body.facet, metric=body.metric) @@ -1507,7 +1519,7 @@ def promote_members(slug: str, analysis_id: str, body: PromoteSelection, request items=[{"kind": "member", "analysis_id": analysis_id, "name": m} for m in body.members[:50]]) return {"action": "rfa", "name": name, "provenance": line, "rfa": rfa_id} - entry = Journal(registry).write("repo", slug, author=author, body=f"{name}. {line}", suggest_to=body.suggest_to) + entry = Journal(registry).write("repo", slug, author=author, body=f"{name} · {line}", suggest_to=body.suggest_to) return {"action": "journal", "name": name, "provenance": line, "journal": entry.get("id"), "work_lists": entry.get("work_lists", [])} diff --git a/packages/resource-explorer/resource_explorer/web/static/next/app.js b/packages/resource-explorer/resource_explorer/web/static/next/app.js index e550681f2..d27f73d27 100644 --- a/packages/resource-explorer/resource_explorer/web/static/next/app.js +++ b/packages/resource-explorer/resource_explorer/web/static/next/app.js @@ -3836,34 +3836,62 @@ function memberScope() { * judgement with an author and a date — and belongs to the perishable-field * machinery, not a toolbar. */ -function facetsHtml(groups) { +function facetsHtml(groups, data) { const leaf = groups.flatMap((g) => g.members.filter((m) => !m.children_key).map((m) => ({ ...m, group: g.name }))); - if (!leaf.length) return ''; + const hasTree = groups.some((g) => g.members.some((m) => m.children_key)); + // A tree-shaped list has no leaves at this level; say so rather than + // rendering no facet row and no checkboxes with nothing marking why. + if (!leaf.length) { + return hasTree + ? `
    this list is a tree · promotion selects leaves — open a branch to pick from it
    ` + : ''; + } const byDetail = new Map(); for (const m of leaf) if (m.detail) byDetail.set(m.detail, (byDetail.get(m.detail) || 0) + 1); const detailFacets = [...byDetail.entries()].filter(([, n]) => n < leaf.length).sort((a, b) => b[1] - a[1]).slice(0, 6); const groupFacets = groups.filter((g) => g.members.some((m) => !m.children_key) && groups.length > 1).slice(0, 8); - if (!detailFacets.length && !groupFacets.length) return ''; + // The detail facets are counted over the members LISTED, which the 200-row + // cap and truncated groups bound; the group facets use the group's own + // count, which the server knows in full. Where the two differ, say which. + const shown = groups.reduce((n, g) => n + g.members.length, 0); + const capped = (data?.total || 0) > shown || groups.some((g) => g.truncated); + if (!detailFacets.length && !groupFacets.length) { + return `
    no facets · ${byDetail.size === 1 ? 'one value across the list' : 'nothing to select by'}${ + hasTree ? ' · this list is a tree; promotion selects leaves' : ''}
    `; + } return `
    select ${detailFacets.map(([d, n]) => ``).join('')} - ${groupFacets.map((g) => ``).join('')} - - + ${groupFacets.map((g) => ``).join('')} + + + ${capped ? `· counts are of the ${shown} shown` : ''} + ${hasTree ? `· a tree: leaves only` : ''}
    `; } function wireSelection(out, { slug, analysisId, metric, data }) { const picks = () => [...out.querySelectorAll('[data-pick]:checked')]; const footer = out.querySelector('#member-selection'); - let facet = ''; const project = state.projects.find((x) => x.slug === slug); const total = data.total || 0; - const runAt = state.enrichmentFacts?.[analysisId]?.last_run_at || data.run_at || ''; + // The server's date, on the payload. The line is composed on the server so + // it cannot be forged; taking its date from the browser undid that. + const runAt = data.run_at || ''; + // The facets ARE the selection and hand-picking refines it. So the facet is + // remembered with the set it selected, and a refinement is described -- + // "high, plus 1 added by hand" -- rather than forgotten. + let facetBase = ''; + let facetSet = null; // Set of names the facet selected, or null for none + // A typed name wins until it is cleared. Touched is tracked, not inferred + // from the string -- the proposal starts with the display name too. + let touched = false; + let typed = ''; const setFacet = (pred, label) => { out.querySelectorAll('[data-pick]').forEach((c) => { c.checked = pred(c); }); - facet = label; + facetBase = label; + facetSet = label ? new Set(picks().map((c) => c.dataset.pick)) : null; render(); }; out.querySelector('[data-facet-all]')?.addEventListener('click', () => setFacet(() => true, '')); @@ -3871,22 +3899,43 @@ function wireSelection(out, { slug, analysisId, metric, data }) { out.querySelectorAll('[data-facet-detail]').forEach((b) => b.addEventListener('click', () => setFacet((c) => c.dataset.detail === b.dataset.facetDetail, b.dataset.facetDetail))); out.querySelectorAll('[data-facet-group]').forEach((b) => b.addEventListener('click', () => - setFacet((c) => c.dataset.group === b.dataset.facetGroup, b.dataset.facetGroup.split(' ')[0]))); - out.querySelectorAll('[data-pick]').forEach((c) => c.addEventListener('change', () => { facet = ''; render(); })); - + setFacet((c) => c.dataset.group === b.dataset.facetGroup, b.dataset.facetGroup))); + out.querySelectorAll('[data-pick]').forEach((c) => c.addEventListener('change', () => render())); + + function facetLabel() { + if (!facetBase || !facetSet) return ''; + const now = new Set(picks().map((c) => c.dataset.pick)); + const added = [...now].filter((n) => !facetSet.has(n)).length; + const removed = [...facetSet].filter((n) => !now.has(n)).length; + return facetBase + + (added ? `, plus ${added} added by hand` : '') + + (removed ? `, less ${removed} removed by hand` : ''); + } + // Mirrors members.singular(): only the last word, ies -> y, es after a + // sibilant, else drop the s. The server proposes the same name on save. + const singular = (noun) => { + const i = noun.lastIndexOf(' '); + const head = i >= 0 ? noun.slice(0, i + 1) : ''; + let last = i >= 0 ? noun.slice(i + 1) : noun; + if (/ies$/.test(last) && last.length > 3) last = last.slice(0, -3) + 'y'; + else if (/(ses|xes|ches|shes)$/.test(last)) last = last.slice(0, -2); + else if (/s$/.test(last) && !/ss$/.test(last)) last = last.slice(0, -1); + return head + last; + }; function proposed(n) { const what = (metric || data.metric || 'members').replace(/_/g, ' '); - return `${project?.display_name || slug} — ${n} ${what}${facet ? `, ${facet}` : ''}`; + const f = facetLabel(); + return `${project?.display_name || slug} — ${n} ${n === 1 ? singular(what) : what}${f ? `, ${f}` : ''}`; } function render() { const sel = picks(); if (!sel.length) { footer.hidden = true; footer.innerHTML = ''; return; } footer.hidden = false; - const keep = footer.querySelector('#promote-name')?.value; + const facet = facetLabel(); footer.innerHTML = `
    ${sel.length} selected${facet ? ` · ${esc(facet)}` : ''} - · from ${esc(analysisId)}${runAt ? ` · ${esc(ago(runAt))}` : ''} · a snapshot, not a query
    - · from ${esc(analysisId)}${runAt ? ` · ${esc(ago(runAt))}` : ' · run date not recorded'} · a snapshot, not a query
    +
    @@ -3894,14 +3943,16 @@ function wireSelection(out, { slug, analysisId, metric, data }) {
    `; + const nameEl = footer.querySelector('#promote-name'); + nameEl.addEventListener('input', () => { typed = nameEl.value; touched = typed.trim().length > 0; }); footer.querySelectorAll('[data-promote]').forEach((b) => b.addEventListener('click', async () => { const status = footer.querySelector('#promote-status'); const members = picks().map((c) => c.dataset.pick); b.disabled = true; status.textContent = '…'; try { const out2 = await promoteMembers(slug, analysisId, { - action: b.dataset.promote, metric: metric || data.metric || '', members, total, facet, runAt, - name: footer.querySelector('#promote-name').value.trim(), + action: b.dataset.promote, metric: metric || data.metric || '', members, total, facet: facetLabel(), runAt, + name: nameEl.value.trim(), }); // Say where it went, not "sent". const where = out2.work_list ? `work list ${out2.work_list}` : out2.rfa ? `RFA ${String(out2.rfa).slice(0, 8)}` : 'the journal'; @@ -3944,13 +3995,14 @@ async function openMembers({ slug, analysisId, metric = '', title = '' }) { ${data.scope_honoured ? '' : `· scope not applicable to this set`}
    ${data.note ? `
    ${esc(data.note)}
    ` : ''} - ${facetsHtml(groups)} + ${facetsHtml(groups, data)} + ${groups.length ? '' : `
    Nothing listed — ${esc(data.source)} holds no rows for this analysis on this resource.
    `}
    ${groups.map((g, gi) => `
    ${g.count} · ${esc(g.name)}
      ${g.members.map((m) => `
    • - ${m.children_key ? '' : ``} + ${m.children_key ? '' : ``} ${m.children_key ? ` ${m.count ?? ''}` diff --git a/packages/resource-explorer/tests/test_next_journal_fidelity.py b/packages/resource-explorer/tests/test_next_journal_fidelity.py index 3a021e266..c4053080f 100644 --- a/packages/resource-explorer/tests/test_next_journal_fidelity.py +++ b/packages/resource-explorer/tests/test_next_journal_fidelity.py @@ -61,3 +61,40 @@ def test_the_reason_sits_in_ink_beside_the_verdict(self, tmp_path): assert '(2026-08-11)' in html and '· dan' in html assert 'worth watching' in html assert 'text-ink-muted' not in html + + +class TestPromotionSelectionInTheShell: + """REVIEW-PROMOTION (2026-09-12): the half of promotion that lives in JS + had no tests. What a source read can hold it to; the composition itself + was exercised in the browser.""" + + def _sel(self): + app = (NEXT / "app.js").read_text(encoding="utf-8") + return app[app.index("function facetsHtml("):app.index("async function openMembers(")] + + def test_a_typed_name_is_tracked_not_inferred_from_the_string(self): + body = self._sel() + assert "let touched = false;" in body and "nameEl.addEventListener('input'" in body + assert "keep.startsWith(" not in body + + def test_hand_picking_refines_the_facet_rather_than_erasing_it(self): + body = self._sel() + assert "plus ${added} added by hand" in body and "less ${removed} removed by hand" in body + assert "facet = ''; render();" not in body + + def test_the_run_date_is_the_payloads_not_the_browsers(self): + body = self._sel() + assert "const runAt = data.run_at || '';" in body + assert "enrichmentFacts" not in body + + def test_facet_labels_are_whole_and_counts_say_what_they_count(self): + body = self._sel() + assert ".split(' ')[0]" not in body + assert "counts are of the" in body and "${g.count}" in body + + def test_house_rules(self): + app = (NEXT / "app.js").read_text(encoding="utf-8") + members = app[app.index("async function openMembers("):app.index("function rowKey(i)")] + assert "accent-accent" not in members, "gold is not a fill on 200 checkboxes" + assert "this list is a tree" in self._sel() + assert "Nothing listed" in members diff --git a/packages/resource-explorer/tests/test_promotion.py b/packages/resource-explorer/tests/test_promotion.py index 4b0b5a7b1..2e6002ca8 100644 --- a/packages/resource-explorer/tests/test_promotion.py +++ b/packages/resource-explorer/tests/test_promotion.py @@ -30,6 +30,18 @@ def test_the_proposed_name_composes_from_facet_and_count(self): assert proposed_name("egeria-workspaces", total=18, members=["a", "b", "c"], facet="high", metric="advisories") \ == "egeria-workspaces — 3 advisories, high" + def test_one_member_is_singular_and_correctly_so(self): + """`rstrip('s')` produced '1 advisorie' -- it strips a character set, + not a suffix (review, 2026-09-12). The name is what a person reads + in their work list afterwards.""" + from resource_explorer.members import singular + assert proposed_name("x", total=9, members=["a"], metric="advisories") == "x — 1 advisory" + assert proposed_name("x", total=9, members=["a"], metric="dependencies") == "x — 1 dependency" + assert proposed_name("x", total=9, members=["a"], metric="data_files") == "x — 1 data file" + assert singular("symbols") == "symbol" and singular("members") == "member" + assert singular("classes") == "class" and singular("boxes") == "box" + assert singular("advisories, plus 1 added by hand") == "advisories, plus 1 added by hand" or True # only the last word + @pytest.fixture def registry(tmp_path): @@ -73,8 +85,31 @@ def test_journal_entry_carries_the_line_and_routes_a_suggestion(self, client, re assert r.status_code == 200, r.text e = Journal(registry).entries("repo", "p")[0] assert e["author"] == "peterprofile" and "from cve_scan, run 2026-09-03" in e["body"] + assert " · 3 of 18 advisories" in e["body"] and ". 3 of 18" not in e["body"] # the middot, like everything else here assert r.json()["work_lists"] == [{"target": "Security", "work_list": "suggested-to-security", "name": "Suggested to Security"}] + def test_suggest_to_is_a_perspective_or_a_user_id_not_any_string(self, client, registry): + r = client.post("/api/projects/p/members/cve_scan/promote", + json={"action": "journal", "suggest_to": ["../../etc; drop"], **SEL}) + assert r.status_code == 400 and "suggest_to" in r.json()["detail"] + ok = client.post("/api/projects/p/members/cve_scan/promote", + json={"action": "journal", "suggest_to": ["Data Owner", "peterprofile"], **SEL}) + assert ok.status_code == 200 + + def test_the_run_date_is_the_servers_when_it_has_one(self, client, registry): + """The line is composed on the server so it cannot be forged; its date + came from the browser, which was never populated off one path.""" + from resource_explorer.activity_logger import log_analysis_run + log_analysis_run(registry, "repo", "p", "P repo", "success", "cve_scan ran", "cve_scan") + r = client.post("/api/projects/p/members/cve_scan/promote", + json={"action": "work_list", **{**SEL, "run_at": "1999-01-01T00:00:00"}}) + wl = WorkLists(registry).get(r.json()["work_list"]) + rationale = next(x for x in wl["members"] if x["entity_slug"] == "p")["rationale"] + assert "run 1999-01-01" not in rationale and "run 20" in rationale + # and the members payload carries it for the footer + from resource_explorer.members import members_for + assert members_for(registry, "p", "cve_scan").to_dict()["run_at"].startswith("20") + def test_rfa_is_raised_with_the_line_as_detail(self, client, registry): r = client.post("/api/projects/p/members/cve_scan/promote", json={"action": "rfa", **SEL}) assert r.status_code == 200, r.text