diff --git a/.github/workflows/node-status.yml b/.github/workflows/node-status.yml new file mode 100644 index 000000000..0fa4d7c55 --- /dev/null +++ b/.github/workflows/node-status.yml @@ -0,0 +1,42 @@ +# Node-status acceptance. This job is not a required check and is not part of +# workspace-landing. It does not edit any existing workflow step. +name: Node status + +on: + pull_request: + paths: + - 'architecture/node-status.schema.json' + - 'records/node_status/**' + - 'docs/site/**' + - 'tools/validate_node_status.py' + - 'tools/current_site_history.py' + - 'tests/test_node_status_atlas.mjs' + - 'tests/fixtures/node_status/**' + - '.github/workflows/node-status.yml' + push: + branches: [main] + paths: + - 'architecture/node-status.schema.json' + - 'records/node_status/**' + - 'docs/site/**' + - 'tools/validate_node_status.py' + - 'tools/current_site_history.py' + - 'tests/test_node_status_atlas.mjs' + - 'tests/fixtures/node_status/**' + - '.github/workflows/node-status.yml' + +permissions: + contents: read + +jobs: + node-status: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Node-status atlas test and validator + run: | + node --test tests/test_node_status_atlas.mjs + python3 -B -S tools/validate_node_status.py records/node_status diff --git a/architecture/node-status.schema.json b/architecture/node-status.schema.json new file mode 100644 index 000000000..2bad95d30 --- /dev/null +++ b/architecture/node-status.schema.json @@ -0,0 +1,149 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "architecture/node-status.schema.json", + "title": "Node status record v0", + "description": "Current view of one graph node. status is freshness of this view only, never scientific validity. scientific_effect stays NONE. owner is null or an object; released_url, when present, stays on that object and does not clear it. source_rev equals sources[0].rev, one value, never comma-joined. Receipts are referenced by sources[].url and sources[].rev and are not copied. predecessor, successor, carried_forward, and refresh_error are optional.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "node_id", + "owner", + "next_action", + "missing_input", + "stops", + "sources", + "source_rev", + "observed_at", + "status", + "scientific_effect" + ], + "properties": { + "schema_version": {"const": 0}, + "node_id": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]*$" + }, + "owner": { + "oneOf": [ + {"type": "null"}, + { + "type": "object", + "additionalProperties": false, + "required": ["id", "evidence_url"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "evidence_url": { + "type": "string", + "pattern": "^https://github\\.com/d6g8k5htny-coder/(main|Math-)/(issues|pull)/[1-9]\\d*#(issuecomment-[1-9]\\d*|pullrequestreview-[1-9]\\d*)$" + }, + "released_url": { + "type": "string", + "pattern": "^https://github\\.com/d6g8k5htny-coder/(main|Math-)/(issues|pull)/[1-9]\\d*#(issuecomment-[1-9]\\d*|pullrequestreview-[1-9]\\d*)$" + } + } + } + ] + }, + "next_action": {"type": "string", "minLength": 1}, + "missing_input": { + "type": "array", + "items": {"type": "string", "minLength": 1} + }, + "stops": { + "type": "array", + "items": {"type": "string", "minLength": 1} + }, + "sources": { + "type": "array", + "minItems": 1, + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["url", "rev"], + "properties": { + "url": { + "type": "string", + "pattern": "^https://github\\.com/d6g8k5htny-coder/(main|Math-)/(blob|tree)/[0-9a-f]{40}/[A-Za-z0-9_./-]+$" + }, + "rev": {"type": "string", "pattern": "^[0-9a-f]{40}$"} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["url", "rev"], + "properties": { + "url": { + "type": "string", + "pattern": "^https://github\\.com/d6g8k5htny-coder/(main|Math-)/(issues|pull)/[1-9]\\d*(#(issuecomment-[1-9]\\d*|pullrequestreview-[1-9]\\d*))?$" + }, + "rev": {"type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$"} + } + } + ] + } + }, + "source_rev": { + "type": "string", + "pattern": "^(?:[0-9a-f]{40}|\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z)$" + }, + "observed_at": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$" + }, + "status": {"enum": ["fresh", "stale", "unknown"]}, + "scientific_effect": {"const": "NONE"}, + "predecessor": { + "oneOf": [ + {"type": "null"}, + { + "type": "object", + "additionalProperties": false, + "required": ["node_id", "url"], + "properties": { + "node_id": {"type": "string", "minLength": 1}, + "url": {"type": "string", "pattern": "^https?://\\S+$"} + } + } + ] + }, + "successor": { + "oneOf": [ + {"type": "null"}, + { + "type": "object", + "additionalProperties": false, + "required": ["node_id", "url"], + "properties": { + "node_id": {"type": "string", "minLength": 1}, + "url": {"type": "string", "pattern": "^https?://\\S+$"} + } + } + ] + }, + "carried_forward": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "text", "source_url", "status"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "text": {"type": "string", "minLength": 1}, + "source_url": {"type": "string", "minLength": 1}, + "status": {"enum": ["open", "resolved"]}, + "resolution_url": { + "type": "string", + "minLength": 1, + "pattern": "^https://github\\.com/d6g8k5htny-coder/(main|Math-)/(issues|pull)/[1-9]\\d*#(issuecomment-[1-9]\\d*|pullrequestreview-[1-9]\\d*)$" + } + } + } + }, + "refresh_error": {"type": ["string", "null"]} + } +} diff --git a/docs/site/README.md b/docs/site/README.md index 76618868a..a6742fa68 100644 --- a/docs/site/README.md +++ b/docs/site/README.md @@ -21,7 +21,7 @@ The front and technical layers use the same existing evidence. No second scienti `tools/current_site_data.py` resolves public main and Math- commits once, verifies their complete Git trees and the seven acquired source files, and extracts exact indented proof displays. Other inventory rows bind paths and Git blobs; their document bytes are not downloaded. The page calls equation blocks excerpts and keeps their surrounding proof scope available by pinned line links. `tools/current_site_build.py` renders one payload offline into marked current sections and static node pages; Research and Formal stay script-free. Current observations and original dated reading cuts have separate identities. -"Resume this node" appears in the atlas inspector and on each current static node page. Both come from `resumeProjection` in `current-model.mjs`; `tools/current_site_history.py` keeps a Python copy, and `tests/test_current_site_resume.mjs` checks that the two agree on every node. The block names the snapshot's graph commit, path, SHA-256 and observation time, and warns readers to refresh from the live record before any ownership- or head-dependent action. Recorded `review_*` fields are labelled as review records, not as the current task, an owner or completion. Next action, missing input, owner evidence and retained stop read "not recorded in this snapshot", because the graph records none of them. A bare `review_issue` number names no repository, so it is not linked. Continuation links come only from the small site-maintained `RESUME_TASK_RECORDS` table of explicitly identified operational pointers; they are not graph metadata, scientific acceptance or source-binding admission. Archived snapshot pages are unchanged. +"Resume this node" appears in the atlas inspector and on each current static node page. Both come from `resumeProjection` in `current-model.mjs`; `tools/current_site_history.py` keeps a Python copy, and `tests/test_current_site_resume.mjs` checks that the two agree on every node. The block names the snapshot's graph commit, path, SHA-256 and observation time, and warns readers to refresh from the live record before any ownership- or head-dependent action. Recorded `review_*` fields are labelled as review records, not as the current task, an owner or completion. Next action, missing input, owner evidence and retained stop read "not recorded in this snapshot" on the snapshot-only projection, because the graph records none of them. The atlas inspector also passes `node-status.json`, the published copy of `records/node_status`, into that same projection. Static node pages stay on the snapshot-only call. An owner object is shown only when `evidence_url` matches the shared comment pattern; `released_url`, when present, stays on that object and the atlas says released. The panel shows the recorded status and `observed_at`, and says stale when `observed_at` is more than 24 hours before the render clock. Status is freshness of that view, not scientific validity, and `scientific_effect` stays NONE. A bare `review_issue` number names no repository, so it is not linked. Continuation links come only from the small site-maintained `RESUME_TASK_RECORDS` table of explicitly identified operational pointers; they are not graph metadata, scientific acceptance or source-binding admission. Archived snapshot pages are unchanged. The configured `.github/workflows/current-site.yml` builds on main pushes and a 30-minute schedule and deploys the complete tested docs artifact through Pages Actions. Each candidate needs its own hosted success and deployed-source acceptance. A failed source refresh cannot reach deployment. Source observations are not mathematical revalidation. Existing diagrams on Explore keep their defined teaching scope; the new source panel links the advancing work. diff --git a/docs/site/atlas.mjs b/docs/site/atlas.mjs index 766be1f63..b65d28566 100644 --- a/docs/site/atlas.mjs +++ b/docs/site/atlas.mjs @@ -1,16 +1,19 @@ -import {currentSnapshot,element,anchor} from './current.mjs?site-release=8a66eb0ad9971fbffe07579998488e81195a024161995c3c4c7d4b34e1ec7262'; -import {hasNode,readState,neighborhood,searchGraph,downstreamReach,requiredDependencyPaths,REQUIRED_ROUTE_SEMANTICS,evidenceAxes,sourceURL,sourceForNode,nodePage,resumeProjection,RESUME_NOT_RECORDED} from './current-model.mjs?site-release=713e81d352fe16f86116e405448ab16604f861c360841ba0f7d7396fd9ccb594'; +import {currentSnapshot,element,anchor} from './current.mjs?site-release=c678bd088183b6548c4b094e8f7db287c9ab07f1ad2064737dc6eee9b82332d8'; +import {hasNode,readState,neighborhood,searchGraph,downstreamReach,requiredDependencyPaths,REQUIRED_ROUTE_SEMANTICS,evidenceAxes,sourceURL,sourceForNode,nodePage,resumeProjection,RESUME_NOT_RECORDED,resumeFieldHref} from './current-model.mjs?site-release=e2a859394fa5607936ba766a76875392cf43b7c424f0c52542b68a539dcdc1dd'; import {createAtlasShell,viewForViewport} from './atlas-shell.mjs?site-release=9fce5f707e3486ef152ffdaa1cf1e4adf7cd9cf9dac116407a69b6528b4c656c'; const byId=id=>document.getElementById(id); const svgElement=(tag,attrs)=>{const n=document.createElementNS('http://www.w3.org/2000/svg',tag);for(const [k,v]of Object.entries(attrs))n.setAttribute(k,String(v));return n;}; // Mirrors the static node page block rendered from the same projection by tools/current_site_history.py. function resumeSection(view){const section=element('section',undefined,'resume-node');section.id='atlas-resume';section.setAttribute('aria-labelledby','atlas-resume-heading');const heading=element('h4','Resume this node');heading.id='atlas-resume-heading'; - const fields=items=>{const dl=element('dl',undefined,'resume-fields');for(const item of items){const dd=element('dd');if(item.url)dd.append(anchor(item.text,item.url));else dd.append(element('code',item.text));if(item.note)dd.append(` ${item.note}`);dl.append(element('dt',item.label||item.field),dd);}return dl;}; + const fields=items=>{const dl=element('dl',undefined,'resume-fields');for(const item of items){const dd=element('dd');const href=resumeFieldHref(item);if(href)dd.append(anchor(item.text,href));else dd.append(element('code',item.text));if(item.note)dd.append(` ${item.note}`);dl.append(element('dt',item.label||item.field),dd);}return dl;}; const s=view.snapshot,warning=element('p',undefined,'resume-warning');warning.dataset.freshness=view.freshness;warning.append(element('strong',`As of ${s.observed_at} · ${view.freshness}.`),` ${view.warning}${view.freshness==='current'?'':' Do not rely on this snapshot for current ownership or heads.'}`); section.append(heading,warning,element('p',`Snapshot identity: graph ${s.graph_repository} commit ${s.graph_commit} · ${s.graph_path} · SHA-256 ${s.graph_sha256} · main commit ${s.main_commit||RESUME_NOT_RECORDED}.`),element('h5','Recorded graph state'),fields(view.state),element('h5','Recorded review record'),element('p',view.review.label),view.review.fields.length?fields(view.review.fields):element('p',`Review metadata: ${RESUME_NOT_RECORDED}.`),element('h5','Operational task state'),fields(view.operational),element('h5','Continuation')); const task=view.continuation,p=element('p');if(task)p.append(anchor(task.label,task.url),`. ${task.meaning} Site-maintained pointer, not part of the graph snapshot; identified on ${task.identified_on} in `,anchor("the coordinator's activation record",task.identified_in),'. The linked comment is mutable; read it live before acting.');else p.textContent=view.continuation_text;section.append(p);return section;} +// Published copy of records/node_status. A missing file leaves the snapshot-only projection. +async function loadNodeStatus(){try{const response=await fetch('node-status.json',{cache:'no-store',credentials:'omit'});if(!response.ok)return null;const body=await response.json();return body&&typeof body==='object'&&!Array.isArray(body)?body:null;}catch{return null;}} function linkRecord(label,value){const p=element('p');if(typeof value==='string'){try{const u=new URL(value);if(u.protocol==='https:'&&u.hostname==='github.com'&&!u.username&&!u.password&&!u.port){p.append(anchor(label,value));return p;}}catch{}}p.textContent=`${label}: ${value||'not recorded'}`;return p;} async function start() { + const nodeStatus=await loadNodeStatus(); const root=byId('current-atlas');if(!root)return; const data=await currentSnapshot; if(!data){byId('atlas-status').textContent='Unavailable: current snapshot did not verify. Use the static source index and preserved snapshot below.';return;} @@ -37,7 +40,7 @@ async function start() { const reachPanel=element('p',reachText);reachPanel.id='atlas-reach';panel.append(reachPanel,element('p',`Complete indexed snapshot query at ${data.graph.identity.commit}. Each count excludes the selected node and counts distinct downstream nodes. All edges includes context; required edges uses required edges at every step. These counts do not identify blocked theorems or prove completeness.`));byId('atlas-diagnostic-selection').textContent=`Selected ${selected}: ${reachText} No scientific disposition is inferred.`; panel.append(element('p',n.scope||n.notes||'Statement and scope extraction not recorded. Inspect the source reference.')); panel.append(anchor('Read this node without JavaScript',nodePage(selected))); - panel.append(resumeSection(resumeProjection(data,selected,Date.now()))); + panel.append(resumeSection(resumeProjection(data,selected,Date.now(),nodeStatus))); const dl=element('dl',undefined,'atlas-axes');for(const a of evidenceAxes(n)){dl.append(element('dt',a.axis),element('dd',`${a.axis[0].toUpperCase()+a.axis.slice(1)} metadata ${a.state==='recorded'?'recorded':'not recorded'} · ${a.state}`));}panel.append(dl,element('p','These labels describe metadata in this graph snapshot. Evidence values, applicability and custody are separate. No explicit graph-to-artifact binding · Artifact roles: unknown · Custody: unknown.')); const source=sourceForNode(data,n);if(source){panel.append(anchor('Immutable source reference (document bytes not acquired by this graph view)',sourceURL(source)));panel.append(element('p',`Git blob ${source.blob} · ${source.bytes} bytes in the pinned tree. Reference presence is not mathematical verification.`));}else panel.append(element('p',n.source?`Unresolved source reference: ${n.source}. No matched public file in this pinned source inventory.`:'Source reference not recorded.')); for(const key of Object.keys(n).filter(k=>k.startsWith('review_'))){const value=n[key];const pinned=key==='review_source'?data.sources.find(s=>s.repository===data.graph.identity.repository&&s.path===value):null;if(pinned)panel.append(anchor('Recorded review source at the pinned commit',sourceURL(pinned)));else panel.append(linkRecord(`Recorded ${key}`,value&&typeof value==='object'?JSON.stringify(value):value));} diff --git a/docs/site/cite.html b/docs/site/cite.html index 8d847fffd..e86bd44a6 100644 --- a/docs/site/cite.html +++ b/docs/site/cite.html @@ -7,7 +7,7 @@ - +
UNIVERSAL LAW MATHEMATICS IN THE OPEN
diff --git a/docs/site/current-model.mjs b/docs/site/current-model.mjs index 7636274b7..02264b21d 100644 --- a/docs/site/current-model.mjs +++ b/docs/site/current-model.mjs @@ -79,17 +79,87 @@ export const RESUME_TASK_RECORDS=Object.freeze({ }); const REVIEW_URL=/^https:\/\/github\.com\/d6g8k5htny-coder\/(?:main|Math-)\/(?:issues|pull)\/[1-9][0-9]*(?:#[A-Za-z0-9_-]+)?$/; const metadataText=value=>typeof value==='string'?value:JSON.stringify(value); +const NODE_STATUS_FORBIDDEN=new Set(['lemma_closed','prizes_solved','discharges_OBL_H5_JETMOD','certified_C_H','freeze','inventable_attempt_accepted']); +const NODE_STATUS_STATE=/^(?:fresh|stale|unknown)$/; +const NODE_STATUS_OBSERVED=/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/; +// Same characters as EVIDENCE_URL_PATTERN in tools/validate_node_status.py. +export const NODE_STATUS_EVIDENCE_URL_PATTERN = String.raw`^https://github\.com/d6g8k5htny-coder/(main|Math-)/(issues|pull)/[1-9]\d*#(issuecomment-[1-9]\d*|pullrequestreview-[1-9]\d*)$`; +export const NODE_STATUS_EVIDENCE_URL = new RegExp(NODE_STATUS_EVIDENCE_URL_PATTERN); +const NODE_STATUS_STALE_MS = 24 * 60 * 60 * 1000; +function nodeStatusForbidden(value){ + if(!value||typeof value!=='object')return false; + return Object.entries(value).some(([key,item])=>NODE_STATUS_FORBIDDEN.has(key)||nodeStatusForbidden(item)); +} +export function nodeStatusEvidenceURL(url){ + return typeof url==='string' && NODE_STATUS_EVIDENCE_URL.test(url) ? url : null; +} +// Owner evidence is linked only when it matches the shared comment pattern. +export function resumeFieldHref(item){ + if(!item || typeof item.url!=='string' || !item.url)return null; + if(item.field==='owner_evidence' && !NODE_STATUS_EVIDENCE_URL.test(item.url))return null; + return item.url; +} +// A record without an evidence URL is not applied. Freshness is not scientific validity. +// released_url stays on the owner object; this reader does not replace that object with null. +function nodeStatusRecord(statusRecords,id){ + if(!statusRecords||typeof statusRecords!=='object'||Array.isArray(statusRecords)||!Object.prototype.hasOwnProperty.call(statusRecords,id))return null; + const record=statusRecords[id]; + if(!record||typeof record!=='object'||Array.isArray(record)||record.scientific_effect!=='NONE'||nodeStatusForbidden(record))return null; + if(!Object.prototype.hasOwnProperty.call(record,'owner'))return null; + const owner=record.owner; + if(owner!==null&&(typeof owner!=='object'||Array.isArray(owner)||typeof owner.id!=='string'||!owner.id||typeof owner.evidence_url!=='string'||!owner.evidence_url))return null; + return record; +} +function nodeStatusList(value){return Array.isArray(value)&&value.every(item=>typeof item==='string'&&item)&&value.length?value.join('; '):null;} +function nodeStatusNote(record, now){ + const recorded=record.status, observed=record.observed_at; + if(typeof recorded!=='string'||!NODE_STATUS_STATE.test(recorded)||typeof observed!=='string'||!NODE_STATUS_OBSERVED.test(observed))return null; + let shown=recorded; + if(Number.isFinite(now)){ + const age=now-Date.parse(observed); + if(Number.isFinite(age)&&age>NODE_STATUS_STALE_MS)shown='stale'; + } + return `Node-status view ${shown} as of ${observed}. Recorded status ${recorded}. Scientific effect NONE. Freshness is not scientific validity.`; +} +function nodeStatusOperational(record, now){ + const owner=record.owner; + return RESUME_OPERATIONAL.map(([field,label])=>{ + const row={field,label,text:RESUME_NOT_RECORDED}; + if(field==='next_action'&&typeof record.next_action==='string'&&record.next_action){ + row.text=record.next_action; + const note=nodeStatusNote(record, now); + if(note)row.note=note; + }else if(field==='missing_input'){const text=nodeStatusList(record.missing_input);if(text)row.text=text;} + else if(field==='owner_evidence'){ + if(owner===null)row.text='No owner recorded in this node-status view.'; + else if(owner&&Object.prototype.hasOwnProperty.call(owner,'released_url')){ + row.text='released'; + const link=nodeStatusEvidenceURL(owner.released_url); + if(link)row.url=link; + else if(typeof owner.released_url==='string'&&owner.released_url)row.text=owner.released_url; + }else if(owner){ + const link=nodeStatusEvidenceURL(owner.evidence_url); + if(link){row.text=owner.id;row.url=link;} + else if(typeof owner.evidence_url==='string'&&owner.evidence_url)row.text=owner.evidence_url; + } + } + else if(field==='retained_stop')row.text=nodeStatusList(record.stops)||'No retained stop recorded in this node-status view.'; + return row; + }); +} // One projection feeds the atlas inspector and the static node pages; only `now` adds a freshness state. -export function resumeProjection(snapshot,id,now) { +// statusRecords is optional. The snapshot-only call stays unchanged for static pages. +export function resumeProjection(snapshot,id,now,statusRecords) { const graph=snapshot?.graph?.data;if(!hasNode(graph,id))return null; const node=graph.nodes[id],identity=snapshot.graph.identity,own=key=>Object.prototype.hasOwnProperty.call(node,key); const task=Object.prototype.hasOwnProperty.call(RESUME_TASK_RECORDS,id)?RESUME_TASK_RECORDS[id]:null; + const record=nodeStatusRecord(statusRecords,id); return {node:id, snapshot:{observed_at:snapshot.observed_at,graph_repository:identity.repository,graph_commit:identity.commit,graph_path:identity.path,graph_sha256:identity.sha256,main_commit:snapshot.repositories.find(r=>r.repository==='d6g8k5htny-coder/main')?.commit??null}, freshness:Number.isFinite(now)?freshness(snapshot.observed_at,now):null,warning:RESUME_FRESHNESS_WARNING, state:['classification','controlling'].map(field=>({field,text:own(field)&&node[field]!==null?metadataText(node[field]):RESUME_NOT_RECORDED})), review:{label:RESUME_REVIEW_LABEL,fields:Object.keys(node).filter(key=>key.startsWith('review_')).map(field=>{const value=node[field];return {field,text:metadataText(value),url:typeof value==='string'&&REVIEW_URL.test(value)?value:null,note:field==='review_issue'&&typeof value==='number'?RESUME_ISSUE_NOTE:null};})}, - operational:RESUME_OPERATIONAL.map(([field,label])=>({field,label,text:RESUME_NOT_RECORDED})), + operational:record?nodeStatusOperational(record, now):RESUME_OPERATIONAL.map(([field,label])=>({field,label,text:RESUME_NOT_RECORDED})), continuation:task?{...task}:null,continuation_text:task?null:RESUME_NO_TASK}; } export const MAX_WINDOW_NODES=150; diff --git a/docs/site/current.mjs b/docs/site/current.mjs index 771822592..936eefb4c 100644 --- a/docs/site/current.mjs +++ b/docs/site/current.mjs @@ -1,4 +1,4 @@ -import {fetchVerifiedSnapshot,freshness,filterSources,sourceURL,projectCurrentAcquisitions,ACQUISITION_LABELS,ACQUISITION_UNAVAILABLE} from './current-model.mjs?site-release=713e81d352fe16f86116e405448ab16604f861c360841ba0f7d7396fd9ccb594'; +import {fetchVerifiedSnapshot,freshness,filterSources,sourceURL,projectCurrentAcquisitions,ACQUISITION_LABELS,ACQUISITION_UNAVAILABLE} from './current-model.mjs?site-release=e2a859394fa5607936ba766a76875392cf43b7c424f0c52542b68a539dcdc1dd'; export function element(tag,text,className) { const e=document.createElement(tag);if(text!==undefined)e.textContent=text;if(className)e.className=className;return e; } diff --git a/docs/site/dependencies.html b/docs/site/dependencies.html index 518434ce0..6b213d5c0 100644 --- a/docs/site/dependencies.html +++ b/docs/site/dependencies.html @@ -13,8 +13,8 @@ - - + + diff --git a/docs/site/explore.html b/docs/site/explore.html index 228f1f2b2..9957d5fbb 100644 --- a/docs/site/explore.html +++ b/docs/site/explore.html @@ -13,7 +13,7 @@ - + diff --git a/docs/site/index.html b/docs/site/index.html index 4bf00582d..655ee67a4 100644 --- a/docs/site/index.html +++ b/docs/site/index.html @@ -7,7 +7,7 @@ - +
UNIVERSAL LAW MATHEMATICS IN THE OPEN
diff --git a/docs/site/measure.html b/docs/site/measure.html index 30fd847bd..3febbaa3f 100644 --- a/docs/site/measure.html +++ b/docs/site/measure.html @@ -13,7 +13,7 @@ - + diff --git a/docs/site/museum.html b/docs/site/museum.html index 6fcda90c7..01dfcfca5 100644 --- a/docs/site/museum.html +++ b/docs/site/museum.html @@ -12,7 +12,7 @@ - + diff --git a/docs/site/node-status.json b/docs/site/node-status.json new file mode 100644 index 000000000..c35554433 --- /dev/null +++ b/docs/site/node-status.json @@ -0,0 +1,74 @@ +{ + "eng.d0-packet-allowlist-fix": { + "schema_version": 0, + "node_id": "eng.d0-packet-allowlist-fix", + "owner": null, + "next_action": "The pinned graph note says a portable fix was verified locally and a writer with push access was still awaited. This view records no owner.", + "missing_input": [ + "No PICKUP comment URL is recorded for an owner of this node." + ], + "stops": [], + "sources": [ + { + "url": "https://github.com/d6g8k5htny-coder/Math-/blob/dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde/frontiers/downstream_gate_20260925/GRAPH.json", + "rev": "dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde" + }, + { + "url": "https://github.com/d6g8k5htny-coder/Math-/blob/dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde/frontiers/downstream_gate_20260925/patches/d0_packet_allowlist.patch", + "rev": "dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde" + } + ], + "source_rev": "dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde", + "observed_at": "2026-10-22T17:00:35Z", + "status": "fresh", + "scientific_effect": "NONE" + }, + "eng.hard-gate": { + "schema_version": 0, + "node_id": "eng.hard-gate", + "owner": null, + "next_action": "Use the pinned downstream-gate package as the recorded engineering control. This view sets no mathematical status.", + "missing_input": [ + "The graph source is the directory frontiers/downstream_gate_20260925/, so this view does not pin one document byte as the control text." + ], + "stops": [], + "sources": [ + { + "url": "https://github.com/d6g8k5htny-coder/Math-/blob/dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde/frontiers/downstream_gate_20260925/GRAPH.json", + "rev": "dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde" + }, + { + "url": "https://github.com/d6g8k5htny-coder/Math-/tree/dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde/frontiers/downstream_gate_20260925", + "rev": "dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde" + } + ], + "source_rev": "dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde", + "observed_at": "2026-10-22T17:00:35Z", + "status": "fresh", + "scientific_effect": "NONE" + }, + "math.side24-coefficient": { + "schema_version": 0, + "node_id": "math.side24-coefficient", + "owner": null, + "next_action": "Read the pinned coefficient proof and its recorded review as a review record only. This view records no owner and sets no mathematical status.", + "missing_input": [ + "No owner evidence URL is recorded for this node." + ], + "stops": [], + "sources": [ + { + "url": "https://github.com/d6g8k5htny-coder/Math-/blob/dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde/frontiers/downstream_gate_20260925/GRAPH.json", + "rev": "dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde" + }, + { + "url": "https://github.com/d6g8k5htny-coder/Math-/blob/dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde/coefficients/side24_v1/PROOF.md", + "rev": "dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde" + } + ], + "source_rev": "dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde", + "observed_at": "2026-10-22T17:00:35Z", + "status": "fresh", + "scientific_effect": "NONE" + } +} diff --git a/docs/site/reproduce.html b/docs/site/reproduce.html index c075f279f..ad09f8b86 100644 --- a/docs/site/reproduce.html +++ b/docs/site/reproduce.html @@ -8,7 +8,7 @@ - +
UNIVERSAL LAW MATHEMATICS IN THE OPEN
diff --git a/docs/site/workspace.html b/docs/site/workspace.html index 1e04f5543..b7a9a10f8 100644 --- a/docs/site/workspace.html +++ b/docs/site/workspace.html @@ -10,7 +10,7 @@ - + diff --git a/records/node_status/eng.d0-packet-allowlist-fix.json b/records/node_status/eng.d0-packet-allowlist-fix.json new file mode 100644 index 000000000..0a7f209b5 --- /dev/null +++ b/records/node_status/eng.d0-packet-allowlist-fix.json @@ -0,0 +1,24 @@ +{ + "schema_version": 0, + "node_id": "eng.d0-packet-allowlist-fix", + "owner": null, + "next_action": "The pinned graph note says a portable fix was verified locally and a writer with push access was still awaited. This view records no owner.", + "missing_input": [ + "No PICKUP comment URL is recorded for an owner of this node." + ], + "stops": [], + "sources": [ + { + "url": "https://github.com/d6g8k5htny-coder/Math-/blob/dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde/frontiers/downstream_gate_20260925/GRAPH.json", + "rev": "dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde" + }, + { + "url": "https://github.com/d6g8k5htny-coder/Math-/blob/dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde/frontiers/downstream_gate_20260925/patches/d0_packet_allowlist.patch", + "rev": "dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde" + } + ], + "source_rev": "dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde", + "observed_at": "2026-10-22T17:00:35Z", + "status": "fresh", + "scientific_effect": "NONE" +} diff --git a/records/node_status/eng.hard-gate.json b/records/node_status/eng.hard-gate.json new file mode 100644 index 000000000..6a502b1d8 --- /dev/null +++ b/records/node_status/eng.hard-gate.json @@ -0,0 +1,24 @@ +{ + "schema_version": 0, + "node_id": "eng.hard-gate", + "owner": null, + "next_action": "Use the pinned downstream-gate package as the recorded engineering control. This view sets no mathematical status.", + "missing_input": [ + "The graph source is the directory frontiers/downstream_gate_20260925/, so this view does not pin one document byte as the control text." + ], + "stops": [], + "sources": [ + { + "url": "https://github.com/d6g8k5htny-coder/Math-/blob/dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde/frontiers/downstream_gate_20260925/GRAPH.json", + "rev": "dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde" + }, + { + "url": "https://github.com/d6g8k5htny-coder/Math-/tree/dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde/frontiers/downstream_gate_20260925", + "rev": "dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde" + } + ], + "source_rev": "dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde", + "observed_at": "2026-10-22T17:00:35Z", + "status": "fresh", + "scientific_effect": "NONE" +} diff --git a/records/node_status/math.side24-coefficient.json b/records/node_status/math.side24-coefficient.json new file mode 100644 index 000000000..ecf9c4272 --- /dev/null +++ b/records/node_status/math.side24-coefficient.json @@ -0,0 +1,24 @@ +{ + "schema_version": 0, + "node_id": "math.side24-coefficient", + "owner": null, + "next_action": "Read the pinned coefficient proof and its recorded review as a review record only. This view records no owner and sets no mathematical status.", + "missing_input": [ + "No owner evidence URL is recorded for this node." + ], + "stops": [], + "sources": [ + { + "url": "https://github.com/d6g8k5htny-coder/Math-/blob/dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde/frontiers/downstream_gate_20260925/GRAPH.json", + "rev": "dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde" + }, + { + "url": "https://github.com/d6g8k5htny-coder/Math-/blob/dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde/coefficients/side24_v1/PROOF.md", + "rev": "dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde" + } + ], + "source_rev": "dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde", + "observed_at": "2026-10-22T17:00:35Z", + "status": "fresh", + "scientific_effect": "NONE" +} diff --git a/tests/fixtures/node_status/math-463-released.json b/tests/fixtures/node_status/math-463-released.json new file mode 100644 index 000000000..9c0c4ecf7 --- /dev/null +++ b/tests/fixtures/node_status/math-463-released.json @@ -0,0 +1,39 @@ +{ + "schema_version": 0, + "node_id": "eng.hard-gate", + "owner": { + "id": "agent-old", + "evidence_url": "https://github.com/d6g8k5htny-coder/Math-/pull/463#issuecomment-6198606495", + "released_url": "https://github.com/d6g8k5htny-coder/Math-/pull/463#pullrequestreview-5537612171" + }, + "next_action": "Read the historical pickup as released. This fixture sets no mathematical status and records no occupied role.", + "missing_input": [ + "The pickup comment was later released." + ], + "stops": [], + "sources": [ + { + "url": "https://github.com/d6g8k5htny-coder/Math-/blob/dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde/frontiers/downstream_gate_20260925/GRAPH.json", + "rev": "dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde" + } + ], + "source_rev": "dbe6c0a1b1a22c7404b0fd173ee0043b98a08cde", + "observed_at": "2026-10-22T17:00:35Z", + "status": "fresh", + "scientific_effect": "NONE", + "predecessor": { + "node_id": "board-old", + "url": "https://github.com/example/repo/issues/1" + }, + "successor": null, + "carried_forward": [ + { + "id": "math-463-pickup", + "text": "Historical pickup later released by native review.", + "source_url": "https://github.com/d6g8k5htny-coder/Math-/pull/463#issuecomment-6198606495", + "status": "resolved", + "resolution_url": "https://github.com/d6g8k5htny-coder/Math-/pull/463#pullrequestreview-5537612171" + } + ], + "refresh_error": null +} diff --git a/tests/test_node_status_atlas.mjs b/tests/test_node_status_atlas.mjs new file mode 100644 index 000000000..2cc97cbee --- /dev/null +++ b/tests/test_node_status_atlas.mjs @@ -0,0 +1,413 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import {mkdtemp, readFile, readdir, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import * as m from '../docs/site/current-model.mjs'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const recordsDir = fileURLToPath(new URL('../records/node_status/', import.meta.url)); +const FORBIDDEN = ['lemma_closed', 'prizes_solved', 'discharges_OBL_H5_JETMOD', 'certified_C_H', 'freeze', 'inventable_attempt_accepted']; +const PATTERN = String.raw`^https://github\.com/d6g8k5htny-coder/(main|Math-)/(issues|pull)/[1-9]\d*#(issuecomment-[1-9]\d*|pullrequestreview-[1-9]\d*)$`; +const DAY = 24 * 60 * 60 * 1000; +const PICKUP = 'https://github.com/d6g8k5htny-coder/Math-/pull/463#issuecomment-6198606495'; +const RELEASED = 'https://github.com/d6g8k5htny-coder/Math-/pull/463#pullrequestreview-5537612171'; +const COMMENT = 'https://github.com/d6g8k5htny-coder/main/issues/475#issuecomment-6346301123'; +const commit = 'a'.repeat(40), main = 'c'.repeat(40), digest = 'b'.repeat(64); +const snapshot = nodes => ({schema_version: 1, observed_at: '2026-10-13T12:00:00Z', scientific_effect: 'NONE', repositories: [{repository: 'd6g8k5htny-coder/main', commit: main, tree: main}, {repository: 'd6g8k5htny-coder/Math-', commit, tree: commit}], sources: [], graph: {identity: {repository: 'd6g8k5htny-coder/Math-', commit, path: 'frontiers/x/GRAPH.json', blob: commit, bytes: 20, sha256: digest}, data: {nodes, edges: []}}, equations: [], references: []}); + +function python(args, options = {}) { + try { + const stdout = execFileSync('python3', ['-B', '-S', ...args], {encoding: 'utf8', cwd: root, stdio: ['pipe', 'pipe', 'pipe'], ...options}); + return {status: 0, stdout, stderr: ''}; + } catch (error) { + return {status: error.status ?? 1, stdout: String(error.stdout ?? ''), stderr: String(error.stderr ?? '')}; + } +} + +function pythonJson(args, options) { + const result = python(args, options); + if (!result.stdout) throw new Error(`python ${args[0]} failed (${result.status}): ${result.stderr}`); + return JSON.parse(result.stdout); +} + +async function records() { + const names = (await readdir(recordsDir)).filter(name => name.endsWith('.json')).sort(); + const entries = await Promise.all(names.map(async name => [name.slice(0, -5), JSON.parse(await readFile(join(recordsDir, name), 'utf8'))])); + return Object.fromEntries(entries); +} + +function flags(value, found = []) { + if (value && typeof value === 'object') for (const [key, item] of Object.entries(value)) { + if (FORBIDDEN.includes(key)) found.push(key); + flags(item, found); + } + return found; +} + +async function validate(record) { + const directory = await mkdtemp(join(tmpdir(), 'node-status-')); + const name = `${record.node_id}.json`; + const path = join(directory, name); + await writeFile(path, JSON.stringify(record)); + const result = python(['tools/validate_node_status.py', directory]); + const saved = JSON.parse(await readFile(path, 'utf8')); + await rm(directory, {recursive: true, force: true}); + return {...result, saved}; +} + +function ownerRow(view) { + return view.operational.find(row => row.field === 'owner_evidence'); +} + +function noteOf(view) { + return view.operational.find(row => row.field === 'next_action').note; +} + +function expectedNote(record, now) { + const recorded = record.status; + const observed = record.observed_at; + let shown = recorded; + if (Number.isFinite(now)) { + const age = now - Date.parse(observed); + if (Number.isFinite(age) && age > DAY) shown = 'stale'; + } + return `Node-status view ${shown} as of ${observed}. Recorded status ${recorded}. Scientific effect NONE. Freshness is not scientific validity.`; +} + +function expectedOwner(record) { + const owner = record.owner; + if (owner === null) return {text: 'No owner recorded in this node-status view.'}; + if (owner && Object.hasOwn(owner, 'released_url')) { + if (m.NODE_STATUS_EVIDENCE_URL.test(owner.released_url)) return {text: 'released', url: owner.released_url}; + return {text: owner.released_url}; + } + if (owner && m.NODE_STATUS_EVIDENCE_URL.test(owner.evidence_url)) return {text: owner.id, url: owner.evidence_url}; + return {text: owner && owner.evidence_url}; +} + +test('published node-status records are real graph nodes with a valid owner and no mathematical flags', async () => { + const view = await records(); + const ids = Object.keys(view); + assert.ok(ids.length >= 2 && ids.length <= 3, ids.join(',')); + const schema = JSON.parse(await readFile(new URL('../architecture/node-status.schema.json', import.meta.url), 'utf8')); + assert.deepEqual(schema.required, ['schema_version', 'node_id', 'owner', 'next_action', 'missing_input', 'stops', 'sources', 'source_rev', 'observed_at', 'status', 'scientific_effect']); + for (const name of ['predecessor', 'successor', 'carried_forward', 'refresh_error']) { + assert.equal(schema.required.includes(name), false, name); + assert.ok(schema.properties[name], name); + } + assert.equal(schema.additionalProperties, false); + assert.deepEqual(schema.properties.status.enum, ['fresh', 'stale', 'unknown']); + assert.equal(schema.properties.scientific_effect.const, 'NONE'); + const ownerObject = schema.properties.owner.oneOf.find(item => item.type === 'object'); + assert.equal(ownerObject.type, 'object'); + assert.equal(ownerObject.additionalProperties, false); + assert.deepEqual(ownerObject.required, ['id', 'evidence_url']); + assert.equal(ownerObject.properties.evidence_url.pattern, PATTERN); + assert.equal(ownerObject.properties.released_url.pattern, PATTERN); + assert.equal(schema.properties.carried_forward.items.properties.resolution_url.pattern, PATTERN); + assert.equal(m.NODE_STATUS_EVIDENCE_URL_PATTERN, PATTERN); + const printed = python(['-c', 'import sys; sys.path.insert(0, "tools"); from validate_node_status import EVIDENCE_URL_PATTERN; from current_site_history import EVIDENCE_URL_PATTERN as shared; assert shared == EVIDENCE_URL_PATTERN; print(EVIDENCE_URL_PATTERN)']); + assert.equal(printed.status, 0, printed.stderr); + assert.equal(printed.stdout.trim(), PATTERN); + const graph = JSON.parse(await readFile(new URL('../docs/site/current.json', import.meta.url), 'utf8')).graph.data.nodes; + const bundle = JSON.parse(await readFile(new URL('../docs/site/node-status.json', import.meta.url), 'utf8')); + assert.deepEqual(bundle, view); + assert.deepEqual(Object.keys(bundle), [...ids].sort()); + for (const [id, record] of Object.entries(view)) { + assert.equal(record.node_id, id); + assert.equal(record.schema_version, 0); + if (record.owner !== null) { + assert.equal(m.NODE_STATUS_EVIDENCE_URL.test(record.owner.evidence_url), true, id); + if (Object.hasOwn(record.owner, 'released_url')) assert.equal(m.NODE_STATUS_EVIDENCE_URL.test(record.owner.released_url), true, id); + } + assert.equal(record.scientific_effect, 'NONE'); + assert.ok(['fresh', 'stale', 'unknown'].includes(record.status)); + assert.equal(record.source_rev, record.sources[0].rev); + assert.equal(flags(record).length, 0); + assert.ok(Object.hasOwn(graph, id), id); + } + const checked = python(['tools/validate_node_status.py', 'records/node_status']); + assert.equal(checked.status, 0, checked.stderr); + assert.match(checked.stdout, /node-status records valid/); + assert.doesNotMatch(checked.stdout + checked.stderr, /skip/i); +}); + +test('a record with an owner but no evidence_url fails validation', async () => { + const base = (await records())['eng.hard-gate']; + const failed = await validate({...base, owner: {id: 'agent 9'}}); + assert.notEqual(failed.status, 0); + assert.match(failed.stderr, /eng\.hard-gate\.json: owner requires evidence_url/); + assert.doesNotMatch(failed.stderr, /Traceback/); + const shaped = {...base, owner: {id: 'shape-only', evidence_url: COMMENT}}; + const passed = await validate(shaped); + assert.equal(passed.status, 0, passed.stderr); + const flagged = {...base, lemma_closed: true}; + const refused = await validate(flagged); + assert.notEqual(refused.status, 0); + assert.match(refused.stderr, /forbidden mathematical status flag: lemma_closed/); + const nested = {...base, carried_forward: [{id: 'x', text: 'y', source_url: COMMENT, status: 'open', lemma_closed: true}]}; + const nestedRefused = await validate(nested); + assert.match(nestedRefused.stderr, /forbidden mathematical status flag: lemma_closed/); + const notDict = await validate({...base, owner: 'agent 9'}); + assert.match(notDict.stderr, /owner must be null or an object with evidence_url/); + assert.doesNotMatch(notDict.stderr, /Traceback/); + const widened = await validate({...base, status: 'current'}); + assert.match(widened.stderr, /status must be fresh, stale, or unknown/); + const looseSha = {...base, sources: [{url: base.sources[0].url, rev: 'not-a-timestamp'}], source_rev: 'not-a-timestamp'}; + const sha = await validate(looseSha); + assert.match(sha.stderr, /sources\[0\]\.rev must be a 40-character commit/); +}); + +test('closed schema, evidence pattern, and primary source_rev are enforced', async () => { + const base = (await records())['eng.hard-gate']; + const extra = await validate({...base, not_in_schema: 1}); + assert.notEqual(extra.status, 0); + assert.match(extra.stderr, /unexpected fields: not_in_schema/); + const script = await validate({...base, owner: {id: 'agent 9', evidence_url: 'javascript:alert(1)'}}); + assert.notEqual(script.status, 0); + assert.match(script.stderr, /owner evidence_url does not match the comment pattern/); + assert.doesNotMatch(script.stderr, /Traceback/); + const zeros = [ + 'https://github.com/d6g8k5htny-coder/main/issues/0#issuecomment-1', + 'https://github.com/d6g8k5htny-coder/main/issues/01#issuecomment-12', + 'https://github.com/d6g8k5htny-coder/main/issues/475#issuecomment-0', + 'https://github.com/d6g8k5htny-coder/main/issues/475#issuecomment-01', + 'https://github.com/d6g8k5htny-coder/main/pull/463#pullrequestreview-0', + 'https://github.com/d6g8k5htny-coder/Math-/pull/0463#issuecomment-6198606495', + 'https://github.com/d6g8k5htny-coder/main/issues/475#pullrequestreview-01', + ]; + for (const url of zeros) { + assert.equal(m.NODE_STATUS_EVIDENCE_URL.test(url), false, url); + const rejected = await validate({...base, owner: {id: 'agent 9', evidence_url: url}}); + assert.notEqual(rejected.status, 0, url); + assert.match(rejected.stderr, /owner evidence_url does not match the comment pattern/); + const released = await validate({...base, owner: {id: 'agent 9', evidence_url: COMMENT, released_url: url}}); + assert.notEqual(released.status, 0, url); + assert.match(released.stderr, /owner released_url does not match the comment pattern/); + } + const ten = 'https://github.com/d6g8k5htny-coder/main/issues/10#issuecomment-10'; + assert.equal(m.NODE_STATUS_EVIDENCE_URL.test(ten), true); + assert.equal((await validate({...base, owner: {id: 'agent 9', evidence_url: ten}})).status, 0); + const older = '2020-01-01T00:00:00Z'; + const newer = '2026-10-22T17:00:35Z'; + const pin = base.sources[0]; + const comment = {url: COMMENT, rev: newer}; + const other = {url: 'https://github.com/d6g8k5htny-coder/main/issues/475#issuecomment-6346276856', rev: older}; + const primary = await validate({...base, sources: [{...other, rev: older}, comment], source_rev: older}); + assert.equal(primary.status, 0, primary.stderr); + const notPrimary = await validate({...base, sources: [other, comment], source_rev: newer}); + assert.notEqual(notPrimary.status, 0); + assert.match(notPrimary.stderr, /source_rev must equal sources\[0\]\.rev/); + const pinPrimary = await validate({...base, sources: [pin, comment], source_rev: pin.rev}); + assert.equal(pinPrimary.status, 0, pinPrimary.stderr); + const joined = await validate({...base, sources: [other, comment], source_rev: `${older},${newer}`}); + assert.notEqual(joined.status, 0); + assert.match(joined.stderr, /source_rev must be a single rev/); + assert.doesNotMatch(joined.stderr, /source_rev must equal sources\[0\]\.rev/); +}); + +test('pinned rev equals the blob or tree commit segment', async () => { + const base = (await records())['eng.hard-gate']; + const pin = 'a'.repeat(40); + const later = 'b'.repeat(40); + const other = 'c'.repeat(40); + const pinned = (kind, sha, path) => `https://github.com/d6g8k5htny-coder/Math-/${kind}/${sha}/${path}`; + const withPin = (url, rev) => ({...base, sources: [{url, rev}], source_rev: rev}); + for (const kind of ['blob', 'tree']) { + const path = kind === 'blob' ? `receipts/${later}.json` : `receipts/${later}`; + const matched = await validate(withPin(pinned(kind, pin, path), pin)); + assert.equal(matched.status, 0, `${kind}\n${matched.stderr}`); + const inPath = await validate(withPin(pinned(kind, pin, path), later)); + assert.notEqual(inPath.status, 0, kind); + assert.match(inPath.stderr, /sources\[0\]\.rev must equal the blob or tree commit/); + assert.doesNotMatch(inPath.stderr, /Traceback/); + const mismatch = await validate(withPin(pinned(kind, pin, 'frontiers/downstream_gate_20260925/GRAPH.json'), other)); + assert.notEqual(mismatch.status, 0, kind); + assert.match(mismatch.stderr, /sources\[0\]\.rev must equal the blob or tree commit/); + } +}); + +test('carried_forward resolution_url uses the shared comment pattern', async () => { + const base = (await records())['eng.hard-gate']; + const item = (resolution_url, status = 'resolved') => ({ + ...base, + carried_forward: [{ + id: 'pickup', + text: 'A carried comment.', + source_url: COMMENT, + status, + resolution_url, + }], + }); + const rejected = [ + 'https://github.com/d6g8k5htny-coder/main/issues/475#issuecomment-0', + 'https://github.com/d6g8k5htny-coder/main/issues/475#issuecomment-01', + 'https://github.com/d6g8k5htny-coder/main/pull/463#pullrequestreview-0', + 'https://github.com/d6g8k5htny-coder/Math-/pull/0463#issuecomment-6198606495', + 'https://github.com/d6g8k5htny-coder/meta-framework/issues/475#issuecomment-12', + 'https://github.com/other-owner/main/issues/475#issuecomment-12', + ]; + for (const url of rejected) { + const failed = await validate(item(url)); + assert.notEqual(failed.status, 0, url); + assert.match(failed.stderr, /carried_forward\[0\] resolution_url does not match the comment pattern/); + assert.doesNotMatch(failed.stderr, /Traceback/); + } + for (const url of [COMMENT, RELEASED]) { + const passed = await validate(item(url)); + assert.equal(passed.status, 0, `${url}\n${passed.stderr}`); + assert.equal(passed.saved.carried_forward[0].resolution_url, url); + } + const open = await validate({ + ...base, + carried_forward: [{id: 'pickup', text: 'Still open.', source_url: 'https://github.com/d6g8k5htny-coder/meta-framework/issues/475#issuecomment-0', status: 'open'}], + }); + assert.equal(open.status, 0, open.stderr); +}); + +test('released_url keeps the owner object and the atlas says released', async () => { + const fixture = JSON.parse(await readFile(new URL('./fixtures/node_status/math-463-released.json', import.meta.url), 'utf8')); + assert.equal(fixture.owner.evidence_url, PICKUP); + assert.equal(fixture.owner.released_url, RELEASED); + assert.notEqual(fixture.owner, null); + const checked = await validate(fixture); + assert.equal(checked.status, 0, checked.stderr); + assert.equal(typeof checked.saved.owner, 'object'); + assert.notEqual(checked.saved.owner, null); + assert.equal(checked.saved.owner.id, 'agent-old'); + assert.equal(checked.saved.owner.evidence_url, PICKUP); + assert.equal(checked.saved.owner.released_url, RELEASED); + assert.equal(checked.saved.predecessor.node_id, 'board-old'); + assert.equal(checked.saved.successor, null); + assert.equal(checked.saved.carried_forward[0].status, 'resolved'); + assert.equal(checked.saved.refresh_error, null); + const data = snapshot({[fixture.node_id]: {kind: 'candidate', classification: 'OPEN_ACTIVE'}}); + const before = fixture.owner; + const shown = m.resumeProjection(data, fixture.node_id, Date.parse(fixture.observed_at), {[fixture.node_id]: fixture}); + assert.equal(fixture.owner, before); + assert.notEqual(fixture.owner, null); + const row = ownerRow(shown); + assert.equal(row.text, 'released'); + assert.equal(row.url, RELEASED); + assert.equal(m.resumeFieldHref(row), RELEASED); + assert.doesNotMatch(JSON.stringify(row), /current owner/); + assert.doesNotMatch(JSON.stringify(row), /agent-old/); + assert.equal(row.label, 'Owner evidence'); + assert.doesNotMatch(row.text, /No owner recorded/); + const script = 'import json,sys\nsys.path.insert(0,sys.argv[1])\nfrom current_site_history import resume_projection\npayload=json.load(sys.stdin)\nprint(json.dumps(resume_projection(payload["data"], payload["id"], payload["records"], payload["now"])["operational"]))'; + const py = pythonJson(['-c', script, fileURLToPath(new URL('../tools/', import.meta.url))], {input: JSON.stringify({data, id: fixture.node_id, records: {[fixture.node_id]: fixture}, now: Date.parse(fixture.observed_at)})}); + assert.deepEqual(py, shown.operational); +}); + +test('resume projection shows a node-status view and refuses an owner without evidence', async () => { + const view = await records(); + const nodes = Object.fromEntries(Object.keys(view).map(id => [id, {kind: 'candidate', classification: 'OPEN_ACTIVE', next_action: 'merge now', owner: 'not an owner'}])); + nodes.other = {kind: 'candidate', classification: 'OPEN_ACTIVE'}; + const data = snapshot(nodes); + for (const id of Object.keys(view)) { + const shown = m.resumeProjection(data, id, undefined, view); + assert.equal(shown.operational.find(row => row.field === 'next_action').text, view[id].next_action); + assert.equal(noteOf(shown), expectedNote(view[id])); + assert.match(noteOf(shown), /Freshness is not scientific validity/); + const want = expectedOwner(view[id]); + assert.equal(ownerRow(shown).text, want.text); + assert.equal(ownerRow(shown).url, want.url); + assert.equal(shown.scientific_effect, undefined); + assert.doesNotMatch(JSON.stringify(shown.operational), /merge now|not an owner/); + assert.deepEqual(flags(shown), []); + } + const bare = m.resumeProjection(data, 'other', undefined, view); + assert.ok(bare.operational.every(row => row.text === m.RESUME_NOT_RECORDED)); + const omitted = m.resumeProjection(data, Object.keys(view)[0]); + assert.ok(omitted.operational.every(row => row.text === m.RESUME_NOT_RECORDED)); + const id = Object.keys(view)[0]; + const ownerless = {[id]: {...view[id], owner: {id: 'agent 9'}}}; + const hidden = m.resumeProjection(data, id, undefined, ownerless); + assert.ok(hidden.operational.every(row => row.text === m.RESUME_NOT_RECORDED)); + assert.doesNotMatch(JSON.stringify(hidden), /agent 9/); + const flagged = {[id]: {...view[id], lemma_closed: true}}; + assert.ok(m.resumeProjection(data, id, undefined, flagged).operational.every(row => row.text === m.RESUME_NOT_RECORDED)); + const effect = {[id]: {...view[id], scientific_effect: 'SOME'}}; + assert.ok(m.resumeProjection(data, id, undefined, effect).operational.every(row => row.text === m.RESUME_NOT_RECORDED)); + const hostile = {[id]: {...view[id], owner: {id: 'agent 9', evidence_url: 'javascript:alert(1)'}}}; + const plain = m.resumeProjection(data, id, undefined, hostile); + assert.equal(ownerRow(plain).url, undefined); + assert.equal(ownerRow(plain).text, 'javascript:alert(1)'); + assert.equal(m.resumeFieldHref({field: 'owner_evidence', text: 'javascript:alert(1)', url: 'javascript:alert(1)'}), null); + assert.equal(m.nodeStatusEvidenceURL('javascript:alert(1)'), null); + const fresh = {...view[id], status: 'fresh', owner: null}; + const freshView = {...view, [id]: fresh}; + const unowned = m.resumeProjection(data, id, undefined, freshView); + assert.equal(ownerRow(unowned).text, 'No owner recorded in this node-status view.'); + assert.equal(ownerRow(unowned).url, undefined); + const observed = fresh.observed_at; + const instant = Date.parse(observed); + const boundary = m.resumeProjection(data, id, instant + DAY, freshView); + assert.equal(noteOf(boundary), expectedNote(fresh, instant + DAY)); + assert.match(noteOf(boundary), /Node-status view fresh as of /); + assert.doesNotMatch(noteOf(boundary), /Node-status view stale/); + const past = m.resumeProjection(data, id, instant + DAY + 1, freshView); + assert.match(noteOf(past), /Node-status view stale as of /); + assert.match(noteOf(past), /Recorded status fresh/); + const unknown = {[id]: {...fresh, status: 'unknown'}}; + const overridden = m.resumeProjection(data, id, instant + DAY + 1, unknown); + assert.match(noteOf(overridden), /Node-status view stale as of /); + assert.match(noteOf(overridden), /Recorded status unknown/); + const tools = fileURLToPath(new URL('../tools/', import.meta.url)); + const script = 'import json,sys\nsys.path.insert(0,sys.argv[1])\nfrom current_site_history import resume_projection\npayload=json.load(sys.stdin)\nprint(json.dumps([resume_projection(payload["data"], i, payload["records"], payload.get("now")).get("operational") for i in payload["ids"]]))'; + const ids = [...Object.keys(view), 'other']; + const py = pythonJson(['-c', script, tools], {input: JSON.stringify({data, ids, records: view})}); + assert.deepEqual(py, ids.map(node => m.resumeProjection(data, node, undefined, view).operational)); + const pyPast = pythonJson(['-c', script, tools], {input: JSON.stringify({data, ids: [id], records: freshView, now: instant + DAY + 1})}); + assert.deepEqual(pyPast, [m.resumeProjection(data, id, instant + DAY + 1, freshView).operational]); + const pyEdge = pythonJson(['-c', script, tools], {input: JSON.stringify({data, ids: [id], records: freshView, now: instant + DAY})}); + assert.deepEqual(pyEdge, [m.resumeProjection(data, id, instant + DAY, freshView).operational]); + const pyScript = pythonJson(['-c', script, tools], {input: JSON.stringify({data, ids: [id], records: hostile})}); + assert.deepEqual(pyScript, [plain.operational]); + const pyOwner = pythonJson(['-c', script, tools], {input: JSON.stringify({data, ids: [id], records: ownerless})}); + assert.deepEqual(pyOwner, [hidden.operational]); + const pyFlag = pythonJson(['-c', script, tools], {input: JSON.stringify({data, ids: [id], records: flagged})}); + assert.deepEqual(pyFlag, [m.resumeProjection(data, id, undefined, flagged).operational]); +}); + +test('a valid observed_at and source_rev refresh still validates and renders', async () => { + const view = await records(); + const used = new Set(Object.values(view).map(record => record.observed_at)); + const refreshedAt = used.has('2026-10-23T17:00:35Z') ? '2026-10-24T17:00:35Z' : '2026-10-23T17:00:35Z'; + const pin = 'e'.repeat(40); + const refreshed = Object.fromEntries(Object.entries(view).map(([id, record]) => { + const source = {...record.sources[0]}; + const current = source.url.match(/\/(?:blob|tree)\/([0-9a-f]{40})\//); + const nextPin = current && current[1] === pin ? 'f'.repeat(40) : pin; + const url = source.url.replace(/\/(blob|tree)\/[0-9a-f]{40}\//, `/$1/${nextPin}/`); + if (url !== source.url) { + source.url = url; + source.rev = nextPin; + } else source.rev = refreshedAt; + return [id, {...record, observed_at: refreshedAt, sources: [source, ...record.sources.slice(1)], source_rev: source.rev}]; + })); + for (const record of Object.values(refreshed)) { + const result = await validate(record); + assert.equal(result.status, 0, result.stderr); + assert.notEqual(record.observed_at, view[record.node_id].observed_at); + } + const id = Object.keys(refreshed)[0]; + const data = snapshot({[id]: {kind: 'candidate', classification: 'OPEN_ACTIVE'}}); + const now = Date.parse(refreshedAt); + const shown = m.resumeProjection(data, id, now, refreshed); + assert.equal(noteOf(shown), expectedNote(refreshed[id], now)); + assert.match(noteOf(shown), new RegExp(`as of ${refreshedAt}`)); + assert.equal(ownerRow(shown).text, expectedOwner(refreshed[id]).text); +}); + +test('the atlas resume section passes the published node-status view into resumeProjection', async () => { + const source = await readFile(new URL('../docs/site/atlas.mjs', import.meta.url), 'utf8'); + assert.match(source, /fetch\('node-status\.json'/); + assert.match(source, /resumeProjection\(data,selected,Date\.now\(\),nodeStatus\)/); + assert.match(source, /function resumeSection\(view\)/); + assert.match(source, /resumeFieldHref\(item\)/); + assert.match(source, /import \{[^}]*resumeFieldHref\} from '\.\/current-model\.mjs/); +}); diff --git a/tools/current_site_history.py b/tools/current_site_history.py index b641e44ca..1c205ac26 100644 --- a/tools/current_site_history.py +++ b/tools/current_site_history.py @@ -8,7 +8,7 @@ bytes. Restore admits all downloads and the durable manifest pin before writes. """ import argparse -from datetime import datetime +from datetime import datetime, timezone import hashlib from html.parser import HTMLParser import io @@ -24,6 +24,7 @@ import zipfile from current_inventory_acquisition import (project_current_acquisitions, current_source_list, inventory_legend, acquisition_notice, date_explanation) +from validate_node_status import EVIDENCE_URL_PATTERN from current_site_data import parse_json from site_asset_release import transform, without_release @@ -1554,14 +1555,119 @@ def _resume_text(value): return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, separators=(",", ":")) -def resume_projection(data, node_id): - """The static form of current-model.mjs resumeProjection; a static page evaluates no freshness.""" +_NODE_STATUS_FORBIDDEN = frozenset({ + "lemma_closed", "prizes_solved", "discharges_OBL_H5_JETMOD", "certified_C_H", + "freeze", "inventable_attempt_accepted"}) +_NODE_STATUS_STATE = re.compile(r"(?:fresh|stale|unknown)\Z") +_NODE_STATUS_OBSERVED = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\Z") +_NODE_STATUS_EVIDENCE = re.compile(EVIDENCE_URL_PATTERN) +_NODE_STATUS_STALE_MS = 24 * 60 * 60 * 1000 + + +def _node_status_forbidden(value): + if isinstance(value, dict): + return any(key in _NODE_STATUS_FORBIDDEN or _node_status_forbidden(item) for key, item in value.items()) + if isinstance(value, list): + return any(_node_status_forbidden(item) for item in value) + return False + + +def _node_status_record(status_records, node_id): + """Optional current view. An owner without evidence_url drops the whole record.""" + if not isinstance(status_records, dict) or node_id not in status_records: + return None + record = status_records[node_id] + if (not isinstance(record, dict) or record.get("scientific_effect") != "NONE" + or _node_status_forbidden(record) or "owner" not in record): + return None + owner = record["owner"] + if owner is not None and (not isinstance(owner, dict) or not isinstance(owner.get("id"), str) or not owner.get("id") + or not isinstance(owner.get("evidence_url"), str) or not owner.get("evidence_url")): + return None + return record + + +def _node_status_list(value): + if isinstance(value, list) and value and all(isinstance(item, str) and item for item in value): + return "; ".join(value) + return None + + +def _node_status_evidence(url): + return url if isinstance(url, str) and _NODE_STATUS_EVIDENCE.fullmatch(url) else None + + +def _node_status_note(record, now): + recorded = record.get("status") + observed = record.get("observed_at") + if not (isinstance(recorded, str) and _NODE_STATUS_STATE.fullmatch(recorded) + and isinstance(observed, str) and _NODE_STATUS_OBSERVED.fullmatch(observed)): + return None + shown = recorded + if isinstance(now, (int, float)) and not isinstance(now, bool): + try: + parsed = datetime.strptime(observed, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) + except ValueError: + parsed = None + if parsed is not None: + age = now - int(parsed.timestamp()) * 1000 + if age > _NODE_STATUS_STALE_MS: + shown = "stale" + return (f"Node-status view {shown} as of {observed}. Recorded status {recorded}. " + "Scientific effect NONE. Freshness is not scientific validity.") + + +def _node_status_operational(record, now=None): + owner = record["owner"] + rows = [] + for field, label in RESUME_OPERATIONAL: + row = {"field": field, "label": label, "text": RESUME_NOT_RECORDED} + if field == "next_action" and isinstance(record.get("next_action"), str) and record["next_action"]: + row["text"] = record["next_action"] + note = _node_status_note(record, now) + if note: + row["note"] = note + elif field == "missing_input": + text = _node_status_list(record.get("missing_input")) + if text: + row["text"] = text + elif field == "owner_evidence": + if owner is None: + row["text"] = "No owner recorded in this node-status view." + elif isinstance(owner, dict) and "released_url" in owner: + row["text"] = "released" + link = _node_status_evidence(owner.get("released_url")) + if link: + row["url"] = link + elif isinstance(owner.get("released_url"), str) and owner["released_url"]: + row["text"] = owner["released_url"] + elif isinstance(owner, dict): + link = _node_status_evidence(owner.get("evidence_url")) + if link: + row["text"] = owner["id"] + row["url"] = link + elif isinstance(owner.get("evidence_url"), str) and owner["evidence_url"]: + row["text"] = owner["evidence_url"] + elif field == "retained_stop": + row["text"] = _node_status_list(record.get("stops")) or "No retained stop recorded in this node-status view." + rows.append(row) + return rows + + +def resume_projection(data, node_id, status_records=None, now=None): + """The static form of current-model.mjs resumeProjection; a static page evaluates no freshness. + + status_records is the optional node-status map. The snapshot-only call matches the + JavaScript default and is what static pages render. now is the render clock in + milliseconds, used only for the node-status stale display. + """ nodes = data["graph"]["data"]["nodes"] if not isinstance(node_id, str) or node_id not in nodes: return None node, identity = nodes[node_id], data["graph"]["identity"] task = RESUME_TASK_RECORDS.get(node_id) main = next((repo["commit"] for repo in data["repositories"] if repo["repository"] == "d6g8k5htny-coder/main"), None) + record = _node_status_record(status_records, node_id) return { "node": node_id, "snapshot": {"observed_at": data["observed_at"], "graph_repository": identity["repository"], @@ -1575,7 +1681,8 @@ def resume_projection(data, node_id): "url": value if isinstance(value, str) and _RESUME_REVIEW_URL.fullmatch(value) else None, "note": RESUME_ISSUE_NOTE if field == "review_issue" and isinstance(value, (int, float)) and not isinstance(value, bool) else None} for field, value in node.items() if field.startswith("review_")]}, - "operational": [{"field": field, "label": label, "text": RESUME_NOT_RECORDED} for field, label in RESUME_OPERATIONAL], + "operational": _node_status_operational(record, now) if record else [ + {"field": field, "label": label, "text": RESUME_NOT_RECORDED} for field, label in RESUME_OPERATIONAL], "continuation": dict(task) if task else None, "continuation_text": None if task else RESUME_NO_TASK, } @@ -1583,8 +1690,16 @@ def resume_projection(data, node_id): def _resume_html(view): snapshot = view["snapshot"] + def _href(item): + url = item.get("url") + if not isinstance(url, str) or not url: + return None + if item.get("field") == "owner_evidence" and not _NODE_STATUS_EVIDENCE.fullmatch(url): + return None + return url + rows = lambda items: "".join(f'
{_v1_escape(item.get("label", item["field"]))}
' + ( - f'{_v1_escape(item["text"])}' if item.get("url") else f'{_v1_escape(item["text"])}') + f'{_v1_escape(item["text"])}' if _href(item) else f'{_v1_escape(item["text"])}') + (f' {_v1_escape(item["note"])}' if item.get("note") else "") + '
' for item in items) review = (f'
{rows(view["review"]["fields"])}
' if view["review"]["fields"] else f'

Review metadata: {RESUME_NOT_RECORDED}.

') diff --git a/tools/validate_node_status.py b/tools/validate_node_status.py new file mode 100644 index 000000000..e9d44b762 --- /dev/null +++ b/tools/validate_node_status.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +"""Validate records/node_status against architecture/node-status.schema.json. + +Status is freshness of the current view only. scientific_effect must stay NONE. +An owner is null or an object. released_url may be present on that object and +is never a reason to replace the owner with null. source_rev must equal +sources[0].rev, one value, never a comma-joined list. No network and no writes. +""" +from __future__ import annotations + +import argparse +import json +from datetime import datetime +from pathlib import Path, PurePosixPath +import re +import sys +from urllib.parse import urlparse + +ROOT = Path(__file__).resolve().parents[1] +REQUIRED = ( + "schema_version", "node_id", "owner", "next_action", "missing_input", "stops", + "sources", "source_rev", "observed_at", "status", "scientific_effect", +) +OPTIONAL = frozenset({"predecessor", "successor", "carried_forward", "refresh_error"}) +FORBIDDEN = frozenset({ + "lemma_closed", "prizes_solved", "discharges_OBL_H5_JETMOD", "certified_C_H", + "freeze", "inventable_attempt_accepted", +}) +NODE_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*\Z") +SHA = re.compile(r"[0-9a-f]{40}\Z") +OBSERVED = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\Z") +# Canonical comment URL: evidence_url, released_url, and carried_forward +# resolution_url. The same characters are NODE_STATUS_EVIDENCE_URL_PATTERN +# in docs/site/current-model.mjs. +EVIDENCE_URL_PATTERN = r"^https://github\.com/d6g8k5htny-coder/(main|Math-)/(issues|pull)/[1-9]\d*#(issuecomment-[1-9]\d*|pullrequestreview-[1-9]\d*)$" +EVIDENCE_URL = re.compile(EVIDENCE_URL_PATTERN) +PINNED_URL = re.compile( + r"^https://github\.com/d6g8k5htny-coder/(main|Math-)/(blob|tree)/" + r"[0-9a-f]{40}/[A-Za-z0-9_./-]+$") + + +def pinned_commit(url): + """Commit in the /blob// or /tree// segment, not elsewhere in the path.""" + if not isinstance(url, str) or PINNED_URL.fullmatch(url) is None: + return None + parts = url.split("/") + if len(parts) < 8 or parts[5] not in ("blob", "tree"): + return None + commit = parts[6] + if SHA.fullmatch(commit) is None: + return None + return commit +GITHUB_OBJECT_URL = re.compile( + r"^https://github\.com/d6g8k5htny-coder/(main|Math-)/(issues|pull)/[1-9]\d*" + r"(#(issuecomment-[1-9]\d*|pullrequestreview-[1-9]\d*))?$") +UTC_REV = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\Z") +CARRIED_SOURCE_URL = re.compile( + r"^https://github\.com/[\w.-]+/[\w.-]+/(?:issues|pull)/\d+" + r"(?:#(?:issuecomment|pullrequestreview)-\d+)?$") +STATUSES = frozenset({"fresh", "stale", "unknown"}) +MAX_FILES = 256 +MAX_BYTES = 65536 + + +def unique_pairs(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate JSON key: {key}") + result[key] = value + return result + + +def forbidden_keys(value, found): + if isinstance(value, dict): + for key, item in value.items(): + if key in FORBIDDEN: + found.append(key) + forbidden_keys(item, found) + elif isinstance(value, list): + for item in value: + forbidden_keys(item, found) + return found + + +def load_json(path, max_bytes=MAX_BYTES): + if path.is_symlink(): + raise ValueError("symlink refused") + if path.stat().st_size > max_bytes: + raise ValueError("record too large") + return json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_pairs) + + +def _http_url(url): + if not isinstance(url, str) or not url or any(ch.isspace() for ch in url): + return False + parsed = urlparse(url) + return parsed.scheme.lower() in ("http", "https") and bool(parsed.netloc) + + +def _closed_schema_problems(record): + extra = sorted(set(record) - set(REQUIRED) - OPTIONAL) + missing = sorted(set(REQUIRED) - set(record)) + problems = [] + if extra: + problems.append("unexpected fields: " + ", ".join(extra)) + if missing: + problems.append("missing fields: " + ", ".join(missing)) + return problems + + +def _owner_object(schema): + for item in schema.get("properties", {}).get("owner", {}).get("oneOf", []): + if item.get("type") == "object": + return item + return None + + +def check_schema_file(root): + path = root / "architecture" / "node-status.schema.json" + problems = [] + try: + schema = load_json(path) + except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as error: + return [f"schema: {error}"] + if schema.get("required") != list(REQUIRED): + problems.append("schema: required fields differ from interface v0") + if schema.get("additionalProperties") is not False: + problems.append("schema: additional properties must be refused") + props = schema.get("properties", {}) + for name in ("predecessor", "successor", "carried_forward", "refresh_error"): + if name not in props: + problems.append(f"schema: optional field {name} missing") + if name in schema.get("required", []): + problems.append(f"schema: {name} must stay optional") + status = props.get("status", {}).get("enum") + if status != ["fresh", "stale", "unknown"]: + problems.append("schema: status must be fresh, stale, or unknown") + effect = props.get("scientific_effect", {}).get("const") + if effect != "NONE": + problems.append("schema: scientific_effect must be NONE") + owner = props.get("owner", {}) + if not any(item.get("type") == "null" for item in owner.get("oneOf", [])): + problems.append("schema: owner must allow null") + owner_object = _owner_object(schema) + if owner_object is None: + problems.append("schema: owner with released_url must stay an object") + else: + owner_props = owner_object.get("properties", {}) + if owner_props.get("evidence_url", {}).get("pattern") != EVIDENCE_URL_PATTERN: + problems.append("schema: evidence_url pattern drift") + if owner_props.get("released_url", {}).get("pattern") != EVIDENCE_URL_PATTERN: + problems.append("schema: released_url pattern drift") + if "released_url" in owner_object.get("required", []): + problems.append("schema: released_url must stay optional") + resolution = ( + props.get("carried_forward", {}) + .get("items", {}) + .get("properties", {}) + .get("resolution_url", {}) + ) + if resolution.get("pattern") != EVIDENCE_URL_PATTERN: + problems.append("schema: resolution_url pattern drift") + return problems + + +def graph_ids(root): + path = root / "docs" / "site" / "current.json" + data = load_json(path, max_bytes=2_000_000) + nodes = data["graph"]["data"]["nodes"] + if not isinstance(nodes, dict): + raise ValueError("current graph nodes are not an object") + return set(nodes) + + +def check_owner(record, problems): + """Read owner. A released_url keeps the owner object; it is not cleared.""" + owner = record["owner"] + if owner is None: + return + if not isinstance(owner, dict): + problems.append("owner must be null or an object with evidence_url") + return + extra = sorted(set(owner) - {"id", "evidence_url", "released_url"}) + if extra: + problems.append("owner allows only id, evidence_url, and released_url") + evidence = owner.get("evidence_url") + if "evidence_url" not in owner or not isinstance(evidence, str) or evidence == "": + problems.append("owner requires evidence_url") + return + if not isinstance(owner.get("id"), str) or owner.get("id") == "": + problems.append("owner id must be a non-empty string") + if not EVIDENCE_URL.fullmatch(evidence): + problems.append("owner evidence_url does not match the comment pattern") + if "released_url" in owner: + released = owner.get("released_url") + if not isinstance(released, str) or not EVIDENCE_URL.fullmatch(released): + problems.append("owner released_url does not match the comment pattern") + + +def check_link(value, field, problems): + if value is None: + return + if not isinstance(value, dict) or set(value) != {"node_id", "url"}: + problems.append(f"{field} must be null or an object with node_id and url") + return + node_id = value.get("node_id") + if not isinstance(node_id, str) or node_id == "" or any(ch.isspace() for ch in node_id): + problems.append(f"{field}.node_id must be a non-empty string") + if not _http_url(value.get("url")): + problems.append(f"{field}.url must be an http(s) URL") + + +def check_carried(value, problems): + if not isinstance(value, list): + problems.append("carried_forward must be a list") + return + seen = set() + allowed = {"id", "text", "source_url", "status", "resolution_url"} + required = {"id", "text", "source_url", "status"} + for index, item in enumerate(value): + label = f"carried_forward[{index}]" + if not isinstance(item, dict) or not required <= set(item) or not set(item) <= allowed: + problems.append(f"{label} must have id, text, source_url, and status") + continue + item_id = item["id"] + if not isinstance(item_id, str) or item_id == "": + problems.append(f"{label}.id must be a non-empty string") + elif item_id in seen: + problems.append(f"{label} duplicates id {item_id}") + else: + seen.add(item_id) + if not isinstance(item["text"], str) or item["text"] == "": + problems.append(f"{label}.text must be a non-empty string") + source = item["source_url"] + if not isinstance(source, str) or not CARRIED_SOURCE_URL.fullmatch(source): + problems.append(f"{label}.source_url must be a GitHub issue or comment URL") + if item["status"] not in ("open", "resolved"): + problems.append(f"{label}.status must be open or resolved") + elif item["status"] == "resolved" and not EVIDENCE_URL.fullmatch(str(item.get("resolution_url", ""))): + problems.append(f"{label} resolution_url does not match the comment pattern") + + +def check_source(source, index, problems): + label = f"sources[{index}]" + if not isinstance(source, dict) or set(source) != {"url", "rev"}: + problems.append(f"{label} must have url and rev") + return + url, rev = source.get("url"), source.get("rev") + pin = pinned_commit(url) + if pin is not None: + if not isinstance(rev, str) or not SHA.fullmatch(rev): + problems.append(f"{label}.rev must be a 40-character commit") + elif rev != pin: + problems.append(f"{label}.rev must equal the blob or tree commit") + return + if isinstance(url, str) and GITHUB_OBJECT_URL.fullmatch(url): + if not isinstance(rev, str) or not UTC_REV.fullmatch(rev): + problems.append(f"{label}.rev must be a UTC timestamp") + return + try: + datetime.strptime(rev, "%Y-%m-%dT%H:%M:%SZ") + except ValueError: + problems.append(f"{label}.rev must be a UTC timestamp") + return + problems.append(f"{label}.url must be a pinned blob or tree, or a GitHub issue, pull, or comment") + + +def _source_rev_problems(record, sources): + source_rev = record["source_rev"] + primary = sources[0].get("rev") if isinstance(sources[0], dict) else None + if not isinstance(source_rev, str) or source_rev == "" or "," in source_rev: + return ["source_rev must be a single rev"] + if source_rev != primary: + return ["source_rev must equal sources[0].rev"] + return [] + + +def check_record(record, filename, known_ids): + problems = [] + if not isinstance(record, dict): + return ["record must be an object"] + flags = forbidden_keys(record, []) + if flags: + problems.append("forbidden mathematical status flag: " + ", ".join(sorted(set(flags)))) + problems.extend(_closed_schema_problems(record)) + if problems: + return problems + if type(record["schema_version"]) is not int or record["schema_version"] != 0: + problems.append("schema_version must be 0") + node_id = record["node_id"] + if not isinstance(node_id, str) or not NODE_ID.fullmatch(node_id): + problems.append("invalid node_id") + elif filename != node_id + ".json": + problems.append("filename must match node_id") + elif node_id not in known_ids: + problems.append("node_id is not in the current graph") + check_owner(record, problems) + if not isinstance(record["next_action"], str) or not record["next_action"]: + problems.append("next_action must be a non-empty string") + for field in ("missing_input", "stops"): + value = record[field] + if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value): + problems.append(f"{field} must be an array of non-empty strings") + sources = record["sources"] + if not isinstance(sources, list) or not sources: + problems.append("sources must be a non-empty array") + else: + for index, source in enumerate(sources): + check_source(source, index, problems) + problems.extend(_source_rev_problems(record, sources)) + for field in ("predecessor", "successor"): + if field in record: + check_link(record[field], field, problems) + if "carried_forward" in record: + check_carried(record["carried_forward"], problems) + if "refresh_error" in record and record["refresh_error"] is not None and not isinstance(record["refresh_error"], str): + problems.append("refresh_error must be a string or null") + observed = record["observed_at"] + if not isinstance(observed, str) or not OBSERVED.fullmatch(observed): + problems.append("observed_at must be UTC Z") + else: + try: + datetime.strptime(observed, "%Y-%m-%dT%H:%M:%SZ") + except ValueError: + problems.append("observed_at is not a real timestamp") + if record["status"] not in STATUSES: + problems.append("status must be fresh, stale, or unknown") + if record["scientific_effect"] != "NONE": + problems.append("scientific_effect must be NONE") + return problems + + +def validate_directory(root, directory): + root = Path(root).resolve() + directory = Path(directory) + if not directory.is_absolute(): + directory = (Path.cwd() / directory).resolve() + problems = list(check_schema_file(root)) + if not directory.is_dir() or directory.is_symlink(): + return problems + ["records directory missing"] + try: + known = graph_ids(root) + except (OSError, UnicodeError, ValueError, json.JSONDecodeError, KeyError) as error: + return problems + [f"current graph: {error}"] + files = sorted(directory.iterdir(), key=lambda item: item.name) + selected = [item for item in files if item.name != ".gitkeep"] + if not selected: + problems.append("no node-status records") + if len(selected) > MAX_FILES: + problems.append("too many node-status records") + seen = set() + for path in selected: + label = path.name + if not path.is_file() or path.is_symlink() or path.suffix != ".json" or PurePosixPath(path.name).name != path.name: + problems.append(f"{label}: expected a JSON record file") + continue + try: + record = load_json(path) + except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as error: + problems.append(f"{label}: {error}") + continue + node_id = record.get("node_id") if isinstance(record, dict) else None + if isinstance(node_id, str): + if node_id in seen: + problems.append(f"{label}: duplicate node_id") + seen.add(node_id) + for problem in check_record(record, path.name, known): + problems.append(f"{label}: {problem}") + return problems + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("directory", type=Path) + parser.add_argument("--root", type=Path, default=ROOT) + args = parser.parse_args(argv) + problems = validate_directory(args.root, args.directory) + if problems: + for problem in problems: + print(problem, file=sys.stderr) + return 1 + print(f"node-status records valid: {args.directory}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())