dsh-standards 0.0.3 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/40-checker/README.md +251 -0
  2. package/40-checker/bin/dsh-levels.mjs +15 -0
  3. package/40-checker/bin/dsh-plugin-verify.mjs +8 -0
  4. package/40-checker/fixtures/bad/index.mjs +12 -0
  5. package/40-checker/fixtures/bad/package.json +15 -0
  6. package/40-checker/fixtures/bad-manifest/index.js +1 -0
  7. package/40-checker/fixtures/bad-manifest/package.json +5 -0
  8. package/40-checker/fixtures/bundle/cordis.patch.yml +3 -0
  9. package/40-checker/fixtures/bundle/package.json +15 -0
  10. package/40-checker/fixtures/bundle/server.mjs +7 -0
  11. package/40-checker/fixtures/bundle-v0/cordis.patch.yml +3 -0
  12. package/40-checker/fixtures/bundle-v0/package.json +21 -0
  13. package/40-checker/fixtures/bundle-v0/server.mjs +7 -0
  14. package/40-checker/fixtures/client-missing/client.js +1 -0
  15. package/40-checker/fixtures/client-missing/index.js +1 -0
  16. package/40-checker/fixtures/client-missing/package.json +6 -0
  17. package/40-checker/fixtures/flow-config/index.js +9 -0
  18. package/40-checker/fixtures/flow-config/package.json +10 -0
  19. package/40-checker/fixtures/fs-write/index.mjs +7 -0
  20. package/40-checker/fixtures/fs-write/package.json +10 -0
  21. package/40-checker/fixtures/good/index.mjs +10 -0
  22. package/40-checker/fixtures/good/package.json +19 -0
  23. package/40-checker/fixtures/hang/index.mjs +5 -0
  24. package/40-checker/fixtures/hang/package.json +10 -0
  25. package/40-checker/fixtures/known-answer/expected.json +18 -0
  26. package/40-checker/fixtures/market-index-real.json +18 -0
  27. package/40-checker/fixtures/market-index.json +18 -0
  28. package/40-checker/fixtures/negative/bad-entry/package.json +1 -0
  29. package/40-checker/fixtures/negative/no-entry/package.json +1 -0
  30. package/40-checker/fixtures/negative/no-patch/package.json +1 -0
  31. package/40-checker/fixtures/negative/root-missing/cordis.patch.yml +2 -0
  32. package/40-checker/fixtures/negative/root-missing/package.json +1 -0
  33. package/40-checker/fixtures/negative/workspace/package.json +1 -0
  34. package/40-checker/package.json +22 -0
  35. package/40-checker/samples/README.md +8 -0
  36. package/40-checker/samples/entry18.json +139 -0
  37. package/40-checker/samples/frame.json +485 -0
  38. package/40-checker/scripts/apply-overlay.mjs +59 -0
  39. package/40-checker/scripts/attestation-check.mjs +151 -0
  40. package/40-checker/scripts/bundle-smoke.mjs +126 -0
  41. package/40-checker/scripts/claim-check.mjs +145 -0
  42. package/40-checker/scripts/clause-map.mjs +94 -0
  43. package/40-checker/scripts/collect-fixtures.mjs +44 -0
  44. package/40-checker/scripts/dashboard.mjs +85 -0
  45. package/40-checker/scripts/dco-check.mjs +147 -0
  46. package/40-checker/scripts/dependabot-check.mjs +40 -0
  47. package/40-checker/scripts/doc-commands-check.mjs +92 -0
  48. package/40-checker/scripts/entry-scan.mjs +190 -0
  49. package/40-checker/scripts/entry-validate.mjs +291 -0
  50. package/40-checker/scripts/entry18-attribution.mjs +66 -0
  51. package/40-checker/scripts/entry18-ci.mjs +685 -0
  52. package/40-checker/scripts/entry18-realweb-summary.mjs +71 -0
  53. package/40-checker/scripts/entry18-web.mjs +307 -0
  54. package/40-checker/scripts/evidence-chain.mjs +100 -0
  55. package/40-checker/scripts/flow-index.mjs +82 -0
  56. package/40-checker/scripts/flow-sample-ci.mjs +529 -0
  57. package/40-checker/scripts/governance-check.mjs +210 -0
  58. package/40-checker/scripts/host-farm.mjs +165 -0
  59. package/40-checker/scripts/journey-config-observe.mjs +109 -0
  60. package/40-checker/scripts/journey-disclosure-shape.mjs +83 -0
  61. package/40-checker/scripts/journey-runtime.mjs +178 -0
  62. package/40-checker/scripts/journey.mjs +76 -0
  63. package/40-checker/scripts/label-drift.mjs +74 -0
  64. package/40-checker/scripts/label-sync.mjs +87 -0
  65. package/40-checker/scripts/label-verify.mjs +90 -0
  66. package/40-checker/scripts/landlock-exec.c +101 -0
  67. package/40-checker/scripts/level-audit.mjs +336 -0
  68. package/40-checker/scripts/m1-sidecar.mjs +126 -0
  69. package/40-checker/scripts/m2-runner.mjs +213 -0
  70. package/40-checker/scripts/round-robin.mjs +88 -0
  71. package/40-checker/scripts/sample-frame.mjs +72 -0
  72. package/40-checker/scripts/sample20-ci.mjs +25 -0
  73. package/40-checker/scripts/sample60-ci.mjs +25 -0
  74. package/40-checker/scripts/stale-check.mjs +59 -0
  75. package/40-checker/scripts/surveillance.mjs +88 -0
  76. package/40-checker/scripts/web-probe.mjs +504 -0
  77. package/40-checker/src/cli.mjs +300 -0
  78. package/40-checker/src/client-shape.mjs +37 -0
  79. package/40-checker/src/declarations.mjs +242 -0
  80. package/40-checker/src/index.mjs +8 -0
  81. package/40-checker/src/install-spec.mjs +67 -0
  82. package/40-checker/src/label.mjs +130 -0
  83. package/40-checker/src/manifest.mjs +381 -0
  84. package/40-checker/src/permissions.mjs +60 -0
  85. package/40-checker/src/report.mjs +204 -0
  86. package/40-checker/src/result.mjs +60 -0
  87. package/40-checker/src/runtime.mjs +299 -0
  88. package/40-checker/src/semver.mjs +194 -0
  89. package/40-checker/src/util.mjs +148 -0
  90. package/40-checker/test/attestation.test.mjs +59 -0
  91. package/40-checker/test/checks.test.mjs +172 -0
  92. package/40-checker/test/claim.test.mjs +28 -0
  93. package/40-checker/test/client-shape.test.mjs +35 -0
  94. package/40-checker/test/dco.test.mjs +51 -0
  95. package/40-checker/test/entry-validate.test.mjs +172 -0
  96. package/40-checker/test/governance.test.mjs +76 -0
  97. package/40-checker/test/label.test.mjs +97 -0
  98. package/40-checker/test/level-audit.test.mjs +99 -0
  99. package/40-checker/test/levels.test.mjs +31 -0
  100. package/40-checker/test/ops.test.mjs +117 -0
  101. package/40-checker/test/permissions.test.mjs +40 -0
  102. package/40-checker/test/semver.test.mjs +32 -0
  103. package/40-checker/test/verify.test.mjs +57 -0
  104. package/CHANGELOG.md +24 -0
  105. package/LICENSE +28 -0
  106. package/LICENSE-APACHE +190 -0
  107. package/LICENSE-CC-BY +23 -0
  108. package/NOTICE +14 -0
  109. package/NOTICE.md +11 -0
  110. package/README.md +146 -3
  111. package/bin/dsh-levels.mjs +4 -0
  112. package/bin/dsh-standards.mjs +19 -0
  113. package/docs/BOOST-FIXTURES-2026-11-02.md +38 -0
  114. package/docs/DEVELOPER-JOURNEY.md +40 -0
  115. package/docs/HOST-J3-BUG-REPORT-DRAFT.md +27 -0
  116. package/docs/HOST-J3-CONFIG-REPRO-2026-11-02.md +36 -0
  117. package/docs/NAMING-FALLBACK.md +11 -0
  118. package/docs/fork-policy.md +12 -0
  119. package/docs/full-test-plan-2026-11-02.md +207 -0
  120. package/docs/incident.md +37 -0
  121. package/docs/onboarding.md +27 -0
  122. package/docs/privacy.md +12 -0
  123. package/docs/release.md +16 -0
  124. package/docs/scope-security.md +62 -0
  125. package/docs/status-taxonomy.md +54 -0
  126. package/docs/sunset.md +44 -0
  127. package/docs/translating.md +12 -0
  128. package/package.json +58 -12
  129. package/spec/README.md +27 -0
  130. package/spec/boost/README.md +8 -0
  131. package/spec/clauses.json +253 -0
  132. package/spec/entry/README.md +9 -0
  133. package/spec/flow/README.md +8 -0
  134. package/spec/levels.json +371 -0
  135. package/spec/levels.schema.json +45 -0
  136. package/spec/reference-suite.json +35 -0
  137. package/spec/schemas/attestation.schema.json +284 -0
  138. package/spec/schemas/evidence.schema.json +331 -0
  139. package/spec/schemas/report.schema.json +64 -0
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * round-robin.mjs — compare two independent runs against frozen known answers.
4
+ *
5
+ * A comparison is `comparable: true` only when:
6
+ * - both runs produced a real status (not not-run/blocked/error);
7
+ * - both statuses match the frozen expected status;
8
+ * - the two runners are not the same identifier.
9
+ *
10
+ * Two machines both returning `not-run` prove nothing. It is not a proficiency
11
+ * test and not an accreditation.
12
+ *
13
+ * Usage:
14
+ * node 40-checker/scripts/round-robin.mjs --a runner-a.json --b runner-b.json \
15
+ * --expected 40-checker/fixtures/known-answer/expected.json --out rounds/2026Q4.json
16
+ */
17
+ import fs from 'node:fs'
18
+ import path from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+
21
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
22
+ const ROOT = path.resolve(HERE, '..', '..')
23
+
24
+ const REAL = new Set(['pass', 'warn', 'fail', 'recognized', 'runtime', 'static', 'measured', 'manual'])
25
+
26
+ export function compare({ a, b, expected }) {
27
+ const rows = []
28
+ const ids = new Set([...Object.keys(expected), ...Object.keys(a.results || {}), ...Object.keys(b.results || {})])
29
+ for (const id of [...ids].sort()) {
30
+ const ae = a.results?.[id]
31
+ const be = b.results?.[id]
32
+ const exp = expected[id]
33
+ const aStatus = ae?.status ?? 'missing'
34
+ const bStatus = be?.status ?? 'missing'
35
+ const ignored = exp?.ignored === true
36
+ const comparable = ignored ? true : Boolean(
37
+ exp && REAL.has(aStatus) && REAL.has(bStatus) &&
38
+ aStatus === bStatus && aStatus === exp.expected &&
39
+ a.runner !== b.runner,
40
+ )
41
+ const problems = []
42
+ if (!exp) problems.push('no frozen expected status')
43
+ if (!REAL.has(aStatus)) problems.push(`runner A status ${aStatus}`)
44
+ if (!REAL.has(bStatus)) problems.push(`runner B status ${bStatus}`)
45
+ if (aStatus !== bStatus) problems.push(`status mismatch: ${aStatus} vs ${bStatus}`)
46
+ if (a.runner === b.runner) problems.push('same runner identifier; not independent')
47
+ rows.push({ id, expected: exp?.expected ?? null, ignored, a: aStatus, b: bStatus, comparable, problems })
48
+ }
49
+ return {
50
+ schema: 'dsh-round-robin/1',
51
+ generatedAt: new Date().toISOString(),
52
+ a: { runner: a.runner, anchor: a.anchor ?? null },
53
+ b: { runner: b.runner, anchor: b.anchor ?? null },
54
+ comparable: rows.length > 0 && rows.every((r) => r.comparable),
55
+ rows,
56
+ note: 'Known-answer comparison only; not a proficiency test, not an accreditation, not a certification.',
57
+ }
58
+ }
59
+
60
+ function read(file) {
61
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
62
+ }
63
+
64
+ export function main(argv = process.argv.slice(2)) {
65
+ let a = null
66
+ let b = null
67
+ let expected = null
68
+ let out = path.join(ROOT, 'rounds', '2026Q4.json')
69
+ for (let i = 0; i < argv.length; i += 1) {
70
+ if (argv[i] === '--a') a = path.resolve(argv[++i])
71
+ else if (argv[i] === '--b') b = path.resolve(argv[++i])
72
+ else if (argv[i] === '--expected') expected = path.resolve(argv[++i])
73
+ else if (argv[i] === '--out') out = path.resolve(argv[++i])
74
+ else if (argv[i] === '--help' || argv[i] === '-h') { console.log('usage: round-robin.mjs --a <json> --b <json> --expected <json> [--out <file>]'); return 0 }
75
+ else { console.error(`unknown argument: ${argv[i]}`); return 2 }
76
+ }
77
+ if (!a || !b || !expected) { console.error('--a, --b, --expected are required'); return 2 }
78
+ const report = compare({ a: read(a), b: read(b), expected: read(expected) })
79
+ fs.mkdirSync(path.dirname(out), { recursive: true })
80
+ fs.writeFileSync(out, `${JSON.stringify(report, null, 2)}\n`)
81
+ for (const row of report.rows) console.log(`${row.comparable ? 'PASS' : 'FAIL'} ${row.id}: expected=${row.expected} a=${row.a} b=${row.b}${row.problems.length ? ` (${row.problems.join('; ')})` : ''}`)
82
+ console.log(`${report.comparable ? 'PASS' : 'FAIL'} comparable=${report.comparable}; wrote ${path.relative(ROOT, out)}`)
83
+ return report.comparable ? 0 : 2
84
+ }
85
+
86
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
87
+ process.exitCode = main()
88
+ }
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Build a reproducible sampling frame from the official dsh-plugin topic.
4
+ * This is a frame, not a pass/fail result: sampled repos may not be installable.
5
+ *
6
+ * Usage: node scripts/sample-frame.mjs [--out samples/frame.json] [--seed 20261031]
7
+ */
8
+ import fs from 'node:fs'
9
+ import path from 'node:path'
10
+
11
+ function arg(name, fallback) {
12
+ const i = process.argv.indexOf(name)
13
+ return i >= 0 ? process.argv[i + 1] : fallback
14
+ }
15
+ const out = path.resolve(arg('--out', 'samples/frame.json'))
16
+ const seed = Number(arg('--seed', '20261031'))
17
+ const headers = { Accept: 'application/vnd.github+json', 'User-Agent': 'dsh-plugin-conformance' }
18
+ if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`
19
+
20
+ async function search(q, sort, order, page = 1, perPage = 100) {
21
+ const url = `https://api.github.com/search/repositories?q=${encodeURIComponent(q)}&sort=${sort}&order=${order}&per_page=${perPage}&page=${page}`
22
+ const res = await fetch(url, { headers })
23
+ if (!res.ok) throw new Error(`GitHub ${res.status}: ${(await res.text()).slice(0, 200)}`)
24
+ return res.json()
25
+ }
26
+
27
+ function hash(text) {
28
+ let h = 2166136261
29
+ for (const ch of text) { h ^= ch.charCodeAt(0); h = Math.imul(h, 16777619) }
30
+ return h >>> 0
31
+ }
32
+
33
+ async function main() {
34
+ const high = []
35
+ for (let page = 1; page <= 3; page += 1) {
36
+ const data = await search('topic:dsh-plugin stars:>50 size:<50000', 'stars', 'desc', page)
37
+ high.push(...data.items)
38
+ }
39
+ const low = []
40
+ for (let page = 1; page <= 2; page += 1) {
41
+ const data = await search('topic:dsh-plugin stars:1..50 size:<50000', 'stars', 'asc', page)
42
+ low.push(...data.items)
43
+ }
44
+ const pool = [...new Map([...high, ...low].map(repo => [repo.full_name, repo])).values()]
45
+ const pick = (items, n) => items.slice().sort((a, b) => hash(`${seed}:${a.full_name}`) - hash(`${seed}:${b.full_name}`)).slice(0, n)
46
+ const sample = pick(pool, 60)
47
+ const lowSample = pick(low, 20)
48
+ const result = {
49
+ schema: 'dsh-plugin-sample/1',
50
+ generatedAt: new Date().toISOString(),
51
+ source: 'GitHub search: topic:dsh-plugin',
52
+ query: { high: 'topic:dsh-plugin stars:>50 size:<50000', low: 'topic:dsh-plugin stars:1..50 size:<50000' },
53
+ seed,
54
+ frameSize: pool.length,
55
+ sampleSize: sample.length,
56
+ lowStarSize: lowSample.length,
57
+ sample: sample.map(repo => ({ fullName: repo.full_name, url: repo.html_url, stars: repo.stargazers_count, updatedAt: repo.updated_at })),
58
+ lowStar: lowSample.map(repo => ({ fullName: repo.full_name, url: repo.html_url, stars: repo.stargazers_count })),
59
+ negativeControls: [
60
+ 'fixtures/negative/no-entry',
61
+ 'fixtures/negative/no-patch',
62
+ 'fixtures/negative/workspace-version',
63
+ 'fixtures/negative/official-label',
64
+ 'fixtures/negative/stale-result',
65
+ ],
66
+ caveat: 'Frame only. A sampled repo may be a listing/monorepo/no-plugin; not a pass/fail result.',
67
+ }
68
+ fs.mkdirSync(path.dirname(out), { recursive: true })
69
+ fs.writeFileSync(out, `${JSON.stringify(result, null, 2)}\n`)
70
+ console.log(`frame=${result.frameSize} sample=${result.sampleSize} low=${result.lowStarSize} -> ${out}`)
71
+ }
72
+ main().catch(error => { console.error(`sample-frame: ${error.message}`); process.exitCode = 3 })
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ /** CI runner: clone 60 frame repos and run ENTRY static. */
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import { spawnSync } from 'node:child_process'
6
+ const STD = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..')
7
+ const frame = JSON.parse(fs.readFileSync(path.join(STD, '40-checker/samples/frame.json'), 'utf8'))
8
+ const out = path.join(STD, 'results/sample20-ci')
9
+ fs.rmSync(out, { recursive: true, force: true }); fs.mkdirSync(out, { recursive: true })
10
+ const rows = []
11
+ for (const repo of frame.lowStar) {
12
+ const id = repo.fullName.replace(/[\\/]/g, '__')
13
+ const dir = path.join('/tmp/dsh-sample20-ci', id)
14
+ const clone = spawnSync('git', ['clone', '--depth', '1', `https://github.com/${repo.fullName}.git`, dir], { encoding: 'utf8', timeout: 120000 })
15
+ if (clone.status !== 0) { rows.push({ id, fullName: repo.fullName, clone: 'fail', error: (clone.stderr || '').slice(-200) }); continue }
16
+ const json = path.join(out, `${id}.json`)
17
+ const run = spawnSync('node', [path.join(STD, '40-checker/bin/dsh-plugin-verify.mjs'), dir, '--scope', 'entry', '--entry-claim', 'static', '--format', 'json', '--out', json], { encoding: 'utf8', timeout: 30000 })
18
+ let status = 'error', level = null
19
+ try { const r = JSON.parse(fs.readFileSync(json, 'utf8')); status = r.conclusion.status; level = r.conclusion.level } catch {}
20
+ rows.push({ id, fullName: repo.fullName, stars: repo.stars, clone: 'ok', status, level, exit: run.status })
21
+ fs.rmSync(dir, { recursive: true, force: true })
22
+ }
23
+ const summary = { schema: 'dsh-sample20-ci/1', generatedAt: new Date().toISOString(), frame: frame.lowStarSize, rows, status: { pass: rows.filter(r => r.status === 'pass').length, warn: rows.filter(r => r.status === 'warn').length, fail: rows.filter(r => r.status === 'fail').length, incomplete: rows.filter(r => r.status === 'incomplete').length, cloneFail: rows.filter(r => r.clone !== 'ok').length, error: rows.filter(r => r.status === 'error').length } }
24
+ fs.writeFileSync(path.join(out, 'summary.json'), JSON.stringify(summary, null, 2) + '\n')
25
+ console.log(JSON.stringify(summary.status))
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ /** CI runner: clone 60 frame repos and run ENTRY static. */
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import { spawnSync } from 'node:child_process'
6
+ const STD = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..')
7
+ const frame = JSON.parse(fs.readFileSync(path.join(STD, '40-checker/samples/frame.json'), 'utf8'))
8
+ const out = path.join(STD, 'results/sample60-ci')
9
+ fs.rmSync(out, { recursive: true, force: true }); fs.mkdirSync(out, { recursive: true })
10
+ const rows = []
11
+ for (const repo of frame.sample) {
12
+ const id = repo.fullName.replace(/[\\/]/g, '__')
13
+ const dir = path.join('/tmp/dsh-sample60-ci', id)
14
+ const clone = spawnSync('git', ['clone', '--depth', '1', `https://github.com/${repo.fullName}.git`, dir], { encoding: 'utf8', timeout: 120000 })
15
+ if (clone.status !== 0) { rows.push({ id, fullName: repo.fullName, clone: 'fail', error: (clone.stderr || '').slice(-200) }); continue }
16
+ const json = path.join(out, `${id}.json`)
17
+ const run = spawnSync('node', [path.join(STD, '40-checker/bin/dsh-plugin-verify.mjs'), dir, '--scope', 'entry', '--entry-claim', 'static', '--format', 'json', '--out', json], { encoding: 'utf8', timeout: 30000 })
18
+ let status = 'error', level = null
19
+ try { const r = JSON.parse(fs.readFileSync(json, 'utf8')); status = r.conclusion.status; level = r.conclusion.level } catch {}
20
+ rows.push({ id, fullName: repo.fullName, stars: repo.stars, clone: 'ok', status, level, exit: run.status })
21
+ fs.rmSync(dir, { recursive: true, force: true })
22
+ }
23
+ const summary = { schema: 'dsh-sample60-ci/1', generatedAt: new Date().toISOString(), frame: frame.sampleSize, rows, status: { pass: rows.filter(r => r.status === 'pass').length, warn: rows.filter(r => r.status === 'warn').length, fail: rows.filter(r => r.status === 'fail').length, incomplete: rows.filter(r => r.status === 'incomplete').length, cloneFail: rows.filter(r => r.clone !== 'ok').length, error: rows.filter(r => r.status === 'error').length } }
24
+ fs.writeFileSync(path.join(out, 'summary.json'), JSON.stringify(summary, null, 2) + '\n')
25
+ console.log(JSON.stringify(summary.status))
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * stale-check.mjs — recompute expiry in the reader's clock.
4
+ *
5
+ * The writer's `status: active` is not trusted. If `expiresAt` is in the past,
6
+ * the effective status is `expired` (and the display must show `stale`).
7
+ *
8
+ * Usage:
9
+ * node 40-checker/scripts/stale-check.mjs [--index publish/attestations/index.json] [--now 2027-01-01] [--json]
10
+ */
11
+ import fs from 'node:fs'
12
+ import path from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+
15
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
16
+ const ROOT = path.resolve(HERE, '..', '..')
17
+ const DEFAULT_INDEX = path.join(ROOT, 'publish', 'attestations', 'index.json')
18
+
19
+ export function effective(entry, now = new Date()) {
20
+ const expires = Date.parse(`${entry.expiresAt}T23:59:59Z`)
21
+ const expired = Number.isFinite(expires) && expires < now.getTime()
22
+ if (entry.status === 'withdrawn') return { status: 'withdrawn', stale: true, reason: 'withdrawn' }
23
+ if (entry.status === 'expired' || expired) return { status: 'expired', stale: true, reason: expired ? 'expiresAt passed' : 'declared expired' }
24
+ if (entry.status === 'draft') return { status: 'draft', stale: false, reason: 'draft is not valid' }
25
+ return { status: entry.status, stale: false, reason: 'within validity' }
26
+ }
27
+
28
+ export function checkIndex(index, now = new Date()) {
29
+ const results = []
30
+ for (const entry of index.attestations || []) {
31
+ results.push({ attestationId: entry.attestationId, declared: entry.status, expiresAt: entry.expiresAt, ...effective(entry, now) })
32
+ }
33
+ return { checkedAt: now.toISOString(), results, stale: results.filter((r) => r.stale).length }
34
+ }
35
+
36
+ export function main(argv = process.argv.slice(2)) {
37
+ let index = DEFAULT_INDEX
38
+ let now = new Date()
39
+ let json = false
40
+ for (let i = 0; i < argv.length; i += 1) {
41
+ if (argv[i] === '--index') index = path.resolve(argv[++i])
42
+ else if (argv[i] === '--now') now = new Date(`${argv[++i]}T00:00:00Z`)
43
+ else if (argv[i] === '--json') json = true
44
+ else if (argv[i] === '--help' || argv[i] === '-h') { console.log('usage: stale-check.mjs [--index <file>] [--now YYYY-MM-DD] [--json]'); return 0 }
45
+ else { console.error(`unknown argument: ${argv[i]}`); return 2 }
46
+ }
47
+ if (!fs.existsSync(index)) { console.error(`index not found: ${index}`); return 2 }
48
+ const result = checkIndex(JSON.parse(fs.readFileSync(index, 'utf8')), now)
49
+ if (json) console.log(JSON.stringify(result, null, 2))
50
+ else {
51
+ for (const row of result.results) console.log(`${row.attestationId} declared=${row.declared} effective=${row.status}${row.stale ? ' STALE' : ''} — ${row.reason}`)
52
+ console.log(`${result.stale} stale/expired record(s) at ${result.checkedAt}`)
53
+ }
54
+ return result.stale > 0 ? 2 : 0
55
+ }
56
+
57
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
58
+ process.exitCode = main()
59
+ }
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * surveillance.mjs — quarterly read-only check of published attestations.
4
+ *
5
+ * It checks: ID uniqueness, expiresAt, declared status, verifyUrl, and evidence
6
+ * links (existence only unless --online). It does not punish, delist, or revoke.
7
+ *
8
+ * Usage:
9
+ * node 40-checker/scripts/surveillance.mjs [--now 2027-01-01] [--out surveillance/2026Q4.json] [--online]
10
+ */
11
+ import fs from 'node:fs'
12
+ import path from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+
15
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
16
+ const ROOT = path.resolve(HERE, '..', '..')
17
+ const INDEX = path.join(ROOT, 'publish', 'attestations', 'index.json')
18
+
19
+ export function inspect(index, now = new Date(), { online = false } = {}) {
20
+ const issues = []
21
+ const records = []
22
+ const seen = new Set()
23
+ for (const entry of index.attestations || []) {
24
+ const record = { attestationId: entry.attestationId, status: entry.status, expiresAt: entry.expiresAt, issues: [] }
25
+ if (seen.has(entry.attestationId)) record.issues.push('duplicate attestationId')
26
+ seen.add(entry.attestationId)
27
+ if (!/^DSH-ATT-\d{4}-\d{3}$/.test(entry.attestationId || '')) record.issues.push('bad attestationId')
28
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(entry.expiresAt || '')) record.issues.push('bad expiresAt')
29
+ else if (Date.parse(`${entry.expiresAt}T23:59:59Z`) < now.getTime()) record.issues.push('expired')
30
+ if (!/^https:\/\//.test(entry.verifyUrl || '')) record.issues.push('verifyUrl must be https')
31
+ if (!entry.claimLevel) record.issues.push('claimLevel missing')
32
+ if (record.issues.length > 0) issues.push({ attestationId: entry.attestationId, issues: record.issues })
33
+ records.push(record)
34
+ }
35
+ return {
36
+ schema: 'dsh-surveillance/1',
37
+ generatedAt: now.toISOString(),
38
+ checked: records.length,
39
+ issues,
40
+ records,
41
+ online,
42
+ note: 'Read-only. No delisting, no punishment, no security judgement.',
43
+ }
44
+ }
45
+
46
+ async function checkOnline(records, index, timeoutMs = 5000) {
47
+ for (const record of records) {
48
+ const entry = (index.attestations || []).find((item) => item.attestationId === record.attestationId)
49
+ if (!entry?.verifyUrl) continue
50
+ const controller = new AbortController()
51
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
52
+ try {
53
+ const response = await fetch(entry.verifyUrl, { method: 'HEAD', signal: controller.signal, redirect: 'follow' })
54
+ record.online = { status: response.status, ok: response.ok }
55
+ if (!response.ok) record.issues.push(`verifyUrl ${response.status}`)
56
+ } catch (error) {
57
+ record.online = { status: null, ok: false, error: error.name }
58
+ record.issues.push(`verifyUrl unreachable: ${error.name}`)
59
+ } finally { clearTimeout(timer) }
60
+ }
61
+ }
62
+
63
+ export async function main(argv = process.argv.slice(2)) {
64
+ let now = new Date()
65
+ let out = path.join(ROOT, 'surveillance', '2026Q4.json')
66
+ let online = false
67
+ for (let i = 0; i < argv.length; i += 1) {
68
+ if (argv[i] === '--now') now = new Date(`${argv[++i]}T00:00:00Z`)
69
+ else if (argv[i] === '--out') out = path.resolve(argv[++i])
70
+ else if (argv[i] === '--online') online = true
71
+ else if (argv[i] === '--help' || argv[i] === '-h') { console.log('usage: surveillance.mjs [--now YYYY-MM-DD] [--out <file>] [--online]'); return 0 }
72
+ else { console.error(`unknown argument: ${argv[i]}`); return 2 }
73
+ }
74
+ const index = JSON.parse(fs.readFileSync(INDEX, 'utf8'))
75
+ const report = inspect(index, now, { online })
76
+ if (online) await checkOnline(report.records, index)
77
+ fs.mkdirSync(path.dirname(out), { recursive: true })
78
+ fs.writeFileSync(out, `${JSON.stringify(report, null, 2)}\n`)
79
+ for (const issue of [...report.issues, ...report.records.flatMap((r) => r.issues.map((i) => ({ attestationId: r.attestationId, issues: [i] })))]) {
80
+ console.log(`WARN ${issue.attestationId}: ${issue.issues.join('; ')}`)
81
+ }
82
+ console.log(`${report.issues.length === 0 ? 'PASS' : 'WARN'} surveillance: ${report.checked} record(s), ${report.issues.length} issue group(s); wrote ${path.relative(ROOT, out)}`)
83
+ return 0
84
+ }
85
+
86
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
87
+ main().then((code) => { process.exitCode = code })
88
+ }