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,204 @@
1
+ import path from 'node:path'
2
+
3
+ export const PACKAGE_MANDATORY = [
4
+ 'DSC-01', 'DSC-02', 'DSC-04', 'DSC-05', 'DSC-06', 'DSC-07', 'DSC-08', 'DSC-09', 'DSC-11', 'DSC-12', 'DSC-13',
5
+ ]
6
+ export const LISTING_MANDATORY = [...PACKAGE_MANDATORY, 'DSC-03']
7
+ export const MVP_CHECKS = ['DSC-01', 'DSC-02', 'DSC-04', 'DSC-05', 'DSC-06', 'DSC-07', 'DSC-08', 'DSC-09']
8
+ export const STATIC_MANDATORY = ['DSC-01', 'DSC-02', 'DSC-03', 'DSC-11', 'DSC-12', 'DSC-13']
9
+ export const ENTRY_RUN = ['DSC-01', 'DSC-02', 'DSC-03', 'DSC-11', 'DSC-12', 'DSC-13', 'DSC-14']
10
+ export const ENTRY_BLOCKING = ['C-1.1', 'C-1.2', 'C-1.4', 'C-3.1', 'C-3.2', 'C-3.5']
11
+ export const ENTRY_RUNTIME_BLOCKING = [...ENTRY_BLOCKING, 'C-2.2', 'C-2.3']
12
+
13
+ function mandatoryFor(scope, profile, staticOnly, entryClaim) {
14
+ if (scope === 'mvp') return MVP_CHECKS
15
+ if (scope === 'entry') return ENTRY_RUN.filter(id => (entryClaim !== 'static' || id !== 'DSC-14') && (id !== 'DSC-03' || profile === 'listing' || profile === 'both'))
16
+ if (staticOnly) return STATIC_MANDATORY
17
+ return profile === 'listing' || profile === 'both' ? LISTING_MANDATORY : PACKAGE_MANDATORY
18
+ }
19
+
20
+ function adjustEntryCheck(item, entryClaim) {
21
+ if (['DSC-11', 'DSC-12', 'DSC-13'].includes(item.id) && item.result === 'FAIL') {
22
+ return { ...item, result: 'WARN', reason: 'entry treats safety/brand fields as WARN; they do not block loadability' }
23
+ }
24
+ if (item.id !== 'DSC-02' || item.result !== 'FAIL') return item
25
+ const details = item.details ?? []
26
+ if (entryClaim === 'static') return { ...item, result: 'WARN', reason: 'entry static claim: version declaration is WARN, not blocking' }
27
+ const hard = details.some(detail => detail.result === 'FAIL' && ['C-2.2', 'C-2.3'].includes(detail.item))
28
+ if (hard) return item
29
+ return { ...item, result: 'WARN', reason: 'C-2.1 version declaration missing/invalid; entry treats it as WARN (C-2.2/C-2.3 remain blocking)' }
30
+ }
31
+
32
+ function allowedNa(check, scope, profile, kind) {
33
+ if (check.result !== 'NA') return false
34
+ if (check.id === 'DSC-03' && scope !== 'mvp' && profile === 'package') return true
35
+ if (check.id === 'DSC-14' && ['client', 'preset', 'skill', 'theme', 'mcp-config', 'unknown'].includes(kind)) return true
36
+ if (['DSC-04', 'DSC-05', 'DSC-06', 'DSC-07', 'DSC-08', 'DSC-09'].includes(check.id) && ['bundle', 'client', 'preset', 'skill', 'theme', 'mcp-config', 'unknown'].includes(kind)) return true
37
+ return false
38
+ }
39
+
40
+ export function buildReport({ subject, anchor, checks, g4 = null, scope, profile, kind, staticOnly = false, entryRuntime = 'require', entryClaim = 'runtime' }) {
41
+ const mandatory = mandatoryFor(scope, profile, staticOnly, entryClaim)
42
+ const adjustedChecks = scope === 'entry' ? checks.map(item => adjustEntryCheck(item, entryClaim)) : checks
43
+ const runtimeIds = new Set(['DSC-04', 'DSC-05', 'DSC-06', 'DSC-07', 'DSC-08', 'DSC-09'])
44
+ const effectiveChecks = staticOnly
45
+ ? adjustedChecks.map(item => runtimeIds.has(item.id) ? { ...item, result: 'NA', coverage: 'not-run', reason: 'out of scope for M1 static-only' } : item)
46
+ : adjustedChecks
47
+ const byId = new Map(effectiveChecks.map(item => [item.id, item]))
48
+ const selected = mandatory.map(id => byId.get(id)).filter(Boolean)
49
+ const missing = mandatory.filter(id => !byId.has(id))
50
+ const failed = selected.filter(item => item.result === 'FAIL')
51
+ const errored = selected.filter(item => item.result === 'ERROR')
52
+ const notRun = selected.filter(item => !allowedNa(item, scope, profile, kind) && (item.coverage === 'not-run' || item.coverage === 'partial'))
53
+ const notRunIds = [...new Set([...missing, ...errored.map(item => item.id), ...notRun.map(item => item.id)])]
54
+ const warnings = selected.filter(item => item.result === 'WARN')
55
+ const pass = selected.filter(item => item.result === 'PASS')
56
+
57
+ let status
58
+ let exitCode
59
+ if (failed.length > 0) { status = 'fail'; exitCode = 1 }
60
+ else if (notRunIds.length > 0) { status = 'incomplete'; exitCode = 2 }
61
+ else if (warnings.length > 0) { status = 'warn'; exitCode = 0 }
62
+ else { status = 'pass'; exitCode = 0 }
63
+
64
+ let level = null
65
+ if (scope === 'entry') {
66
+ const run = byId.get('DSC-14')
67
+ if (entryClaim === 'static') {
68
+ level = (status === 'pass' || status === 'warn') ? `DSH-ENTRY recognized${status === 'warn' ? ' (warnings)' : ''}` : null
69
+ } else if ((status === 'pass' || status === 'warn') && run?.result === 'PASS') {
70
+ level = `DSH-ENTRY runtime${entryRuntime === 'server-entry' ? ' (server-entry)' : ''}${status === 'warn' ? ' (warnings)' : ''}`
71
+ } else if (status === 'pass' || status === 'warn') {
72
+ level = `DSH-ENTRY static${status === 'warn' ? ' (warnings)' : ''}`
73
+ }
74
+ } else if (scope !== 'mvp' && (status === 'pass' || status === 'warn')) {
75
+ const suffix = status === 'warn' ? ', warnings' : ''
76
+ level = staticOnly ? `DS-C static${status === 'warn' ? ' (warnings)' : ''}` : profile === 'listing' || profile === 'both' ? `DS-C (listing${suffix})` : `DS-C (package${suffix})`
77
+ }
78
+
79
+ const reasonParts = []
80
+ if (failed.length > 0) reasonParts.push(`FAIL: ${failed.map(item => item.id).join(', ')}`)
81
+ if (notRunIds.length > 0) reasonParts.push(`not run: ${notRunIds.join(', ')}`)
82
+ if (warnings.length > 0) reasonParts.push(`WARN: ${warnings.map(item => item.id).join(', ')}`)
83
+
84
+ return {
85
+ schema: 'dsh-conformance/1',
86
+ standard: { id: 'DS-C', version: '1.0.0' },
87
+ subject,
88
+ anchor,
89
+ g4,
90
+ checks: effectiveChecks,
91
+ coverage: {
92
+ mandatory: mandatory.length,
93
+ pass: pass.length,
94
+ warn: warnings.length,
95
+ fail: failed.length,
96
+ na: selected.filter(item => item.result === 'NA').length,
97
+ notRun: notRunIds.length,
98
+ error: errored.length,
99
+ entryBlocking: scope === 'entry' ? (() => {
100
+ const blocking = entryClaim === 'static' ? ENTRY_BLOCKING : ENTRY_RUNTIME_BLOCKING
101
+ const failedItems = effectiveChecks.flatMap(item => (item.details ?? []).filter(detail => detail.result === 'FAIL' && blocking.includes(detail.item)).map(detail => detail.item))
102
+ return { required: blocking.length, failed: failedItems, pass: failedItems.length === 0 }
103
+ })() : null,
104
+ },
105
+ conclusion: {
106
+ level,
107
+ status,
108
+ exitCode,
109
+ profile,
110
+ scope,
111
+ subset: scope === 'mvp' ? 'MVP-13' : null,
112
+ staticOnly,
113
+ entryRuntime: scope === 'entry' ? entryRuntime : null,
114
+ entryClaim: scope === 'entry' ? entryClaim : null,
115
+ reason: reasonParts.join('; '),
116
+ },
117
+ }
118
+ }
119
+
120
+ const ICON = { PASS: 'PASS', WARN: 'WARN', FAIL: 'FAIL', NA: 'NA ', ERROR: 'ERR ' }
121
+
122
+ export function formatText(report) {
123
+ const lines = []
124
+ const { subject, anchor, conclusion, coverage } = report
125
+ lines.push(`DS-C check — ${subject.name ?? '<unknown>'}@${subject.version ?? '?'} (${subject.kind}${subject.kindInferred ? ', inferred' : ''})`)
126
+ lines.push(`anchor: repo=${anchor.repo} commit=${anchor.commit.slice(0, 12)} dsh=${anchor.dsh} cordis=${anchor.cordis}`)
127
+ lines.push(`scope=${conclusion.scope} profile=${conclusion.profile} status=${conclusion.status} exit=${conclusion.exitCode}${conclusion.subset ? ` subset=${conclusion.subset}` : ''}${conclusion.staticOnly ? ' static-only' : ''}`)
128
+ if (conclusion.level) lines.push(`level: ${conclusion.level}`)
129
+ lines.push('')
130
+ lines.push('id result clause layer reason')
131
+ for (const item of report.checks) {
132
+ const reason = (item.reason || '').replace(/\s+/g, ' ').slice(0, 120)
133
+ lines.push(`${item.id.padEnd(7)} ${ICON[item.result] ?? item.result} ${item.clause.padEnd(6)} ${item.layer.padEnd(6)} ${reason}`)
134
+ for (const detail of item.details ?? []) {
135
+ if (detail.result === 'PASS') continue
136
+ lines.push(` - ${detail.item}: ${detail.result} ${String(detail.reason).slice(0, 140)}`)
137
+ }
138
+ }
139
+ lines.push('')
140
+ lines.push(`coverage: mandatory=${coverage.mandatory} pass=${coverage.pass} warn=${coverage.warn} fail=${coverage.fail} na=${coverage.na} notRun=${coverage.notRun}`)
141
+ if (conclusion.reason) lines.push(`reason: ${conclusion.reason}`)
142
+ if (conclusion.status === 'incomplete') lines.push('NOT A DS-C PASS: at least one mandatory check did not run (see notRun).')
143
+ lines.push('')
144
+ return `${lines.join('\n')}\n`
145
+ }
146
+
147
+ function sarifLevel(item) {
148
+ if (item.result === 'FAIL' || item.result === 'ERROR') return 'error'
149
+ if (item.result === 'WARN') return 'warning'
150
+ return 'note'
151
+ }
152
+
153
+ function locationOf(item) {
154
+ const evidence = item.evidence?.find(value => /^[^:]+:\d+$/.test(value))
155
+ if (!evidence) return undefined
156
+ const index = evidence.lastIndexOf(':')
157
+ const file = evidence.slice(0, index)
158
+ const line = Number(evidence.slice(index + 1))
159
+ return {
160
+ physicalLocation: {
161
+ artifactLocation: { uri: file.replace(/\\/g, '/') },
162
+ region: { startLine: Number.isFinite(line) && line > 0 ? line : 1 },
163
+ },
164
+ }
165
+ }
166
+
167
+ export function formatSarif(report) {
168
+ const rules = report.checks.map(item => ({
169
+ id: item.id,
170
+ name: item.id,
171
+ shortDescription: { text: `${item.clause} ${item.layer}` },
172
+ fullDescription: { text: `${item.clause} conformance check; coverage=${item.coverage}${item.advisory ? '; advisory' : ''}` },
173
+ properties: { clause: item.clause, layer: item.layer, coverage: item.coverage, advisory: item.advisory === true },
174
+ }))
175
+ const results = report.checks
176
+ .filter(item => item.result !== 'PASS' && item.result !== 'NA')
177
+ .map(item => ({
178
+ ruleId: item.id,
179
+ level: sarifLevel(item),
180
+ message: { text: item.reason || `${item.result} ${item.clause}` },
181
+ locations: locationOf(item) ? [locationOf(item)] : [],
182
+ properties: { clause: item.clause, result: item.result, coverage: item.coverage, advisory: item.advisory === true },
183
+ }))
184
+ return {
185
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
186
+ version: '2.1.0',
187
+ runs: [{
188
+ tool: { driver: { name: 'dsh-plugin-verify', version: '0.1.0', informationUri: 'https://github.com/dsh-community/dsh-plugin-verify', rules } },
189
+ properties: { standard: report.standard, anchor: report.anchor, conclusion: report.conclusion, subject: report.subject },
190
+ results,
191
+ }],
192
+ }
193
+ }
194
+
195
+ export function writeReport(report, format) {
196
+ if (format === 'json') return JSON.stringify(report, null, 2)
197
+ if (format === 'sarif') return JSON.stringify(formatSarif(report), null, 2)
198
+ return formatText(report)
199
+ }
200
+
201
+ export function defaultOutPath(dir, format) {
202
+ const extension = format === 'json' ? 'json' : format === 'sarif' ? 'sarif' : 'txt'
203
+ return path.join(dir, `dsh-ds-c.${extension}`)
204
+ }
@@ -0,0 +1,60 @@
1
+ /** Shared result shape for every check. */
2
+
3
+ /**
4
+ * @param {object} input
5
+ * @param {string} input.id
6
+ * @param {string} input.clause
7
+ * @param {string} input.layer
8
+ * @param {'PASS'|'WARN'|'FAIL'|'NA'|'ERROR'} input.result
9
+ * @param {string} [input.reason]
10
+ * @param {string[]} [input.evidence]
11
+ * @param {object[]} [input.details]
12
+ * @param {'full'|'partial'|'not-run'} [input.coverage]
13
+ * @param {'M'|'M+H'|'H'} [input.method]
14
+ * @param {boolean} [input.advisory]
15
+ */
16
+ export function check(input) {
17
+ return {
18
+ id: input.id,
19
+ clause: input.clause,
20
+ layer: input.layer,
21
+ result: input.result,
22
+ coverage: input.coverage ?? (input.result === 'NA' ? 'not-run' : 'full'),
23
+ reason: input.reason ?? '',
24
+ evidence: input.evidence ?? [],
25
+ details: input.details ?? [],
26
+ method: input.method ?? 'M',
27
+ advisory: input.advisory === true,
28
+ }
29
+ }
30
+
31
+ /**
32
+ * @param {string} item e.g. "C-1.1"
33
+ * @param {'PASS'|'WARN'|'FAIL'|'NA'|'ERROR'} result
34
+ * @param {string} reason
35
+ * @param {string[]} [evidence]
36
+ */
37
+ export function detail(item, result, reason, evidence = []) {
38
+ return { item, result, reason, evidence }
39
+ }
40
+
41
+ const RANK = { ERROR: 5, FAIL: 4, WARN: 3, PASS: 2, NA: 1 }
42
+
43
+ export function worst(results) {
44
+ let current = 'NA'
45
+ for (const result of results) {
46
+ if (!result) continue
47
+ if (RANK[result] > RANK[current]) current = result
48
+ }
49
+ return current
50
+ }
51
+
52
+ export function countByResult(checks) {
53
+ const counts = { PASS: 0, WARN: 0, FAIL: 0, NA: 0, ERROR: 0 }
54
+ for (const item of checks) counts[item.result] = (counts[item.result] ?? 0) + 1
55
+ return counts
56
+ }
57
+
58
+ export function notRun(check, reason) {
59
+ return { ...check, result: 'ERROR', coverage: 'not-run', reason }
60
+ }
@@ -0,0 +1,299 @@
1
+ import path from 'node:path'
2
+ import { pathToFileURL } from 'node:url'
3
+ import { check, detail, worst } from './result.mjs'
4
+ import { regexHits } from './util.mjs'
5
+
6
+ const ACTIVE = 2
7
+
8
+ function classifyImportError(message) {
9
+ const missing = /Cannot find (?:module|package) ['"]([^'"]+)['"]/.exec(message)
10
+ if (missing) {
11
+ const specifier = missing[1]
12
+ // A missing relative file is a packaging bug; a missing bare package is
13
+ // an environment/host-farm gap (the checker cannot tell whether the
14
+ // package manager would have installed it).
15
+ return specifier.startsWith('.') || specifier.startsWith('/') ? 'fail' : 'blocked'
16
+ }
17
+ if (/timed out/.test(message)) return 'blocked'
18
+ return 'fail'
19
+ }
20
+
21
+ function makeStub() {
22
+ const fn = function stub() {}
23
+ return new Proxy(fn, {
24
+ get(_target, property) {
25
+ if (property === 'then') return undefined
26
+ if (property === 'toJSON') return () => ({})
27
+ if (property === Symbol.toPrimitive) return () => ''
28
+ if (property === Symbol.iterator) return function* empty() {}
29
+ return makeStub()
30
+ },
31
+ apply() { return makeStub() },
32
+ construct() { return makeStub() },
33
+ })
34
+ }
35
+
36
+ function withTimeout(promise, timeoutMs, label) {
37
+ let timer
38
+ const timeout = new Promise((_, reject) => {
39
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs)
40
+ if (typeof timer.unref === 'function') timer.unref()
41
+ })
42
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
43
+ }
44
+
45
+ function declaredInject(module) {
46
+ const value = module?.inject ?? module?.default?.inject
47
+ if (Array.isArray(value)) return value.filter(item => typeof item === 'string')
48
+ if (value && typeof value === 'object') return Object.keys(value)
49
+ return []
50
+ }
51
+
52
+ function declaredProvide(module) {
53
+ const value = module?.provide ?? module?.default?.provide
54
+ if (typeof value === 'string') return [value]
55
+ if (Array.isArray(value)) return value.filter(item => typeof item === 'string')
56
+ return []
57
+ }
58
+
59
+ function pluginOf(module) {
60
+ if (typeof module?.default === 'function') return module.default
61
+ if (typeof module?.apply === 'function') return module
62
+ if (typeof module === 'function') return module
63
+ return null
64
+ }
65
+
66
+ function implFiber(impl) {
67
+ return impl?.ctx?.fiber ?? impl?.fiber ?? null
68
+ }
69
+
70
+ function storeEntries(ctx) {
71
+ const store = ctx.reflect.store ?? {}
72
+ return Reflect.ownKeys(store).map(key => store[key]).filter(Boolean)
73
+ }
74
+
75
+ function ownedService(ctx, fiber, name) {
76
+ return storeEntries(ctx).find(impl => impl?.name === name && implFiber(impl) === fiber) ?? null
77
+ }
78
+
79
+ function serviceNames(ctx, fiber) {
80
+ return new Set(storeEntries(ctx).filter(impl => implFiber(impl) === fiber).map(impl => impl.name))
81
+ }
82
+
83
+ function makeNa(reason) {
84
+ return [
85
+ check({ id: 'DSC-04', clause: 'C-4', layer: 'L1', result: 'NA', reason }),
86
+ check({ id: 'DSC-05', clause: 'C-5', layer: 'L1', result: 'NA', reason }),
87
+ check({ id: 'DSC-06', clause: 'C-6', layer: 'L1+L2', result: 'NA', reason }),
88
+ check({ id: 'DSC-07', clause: 'C-7', layer: 'L2', result: 'NA', reason }),
89
+ check({ id: 'DSC-08', clause: 'C-8', layer: 'L3', result: 'NA', reason }),
90
+ check({ id: 'DSC-09', clause: 'C-9', layer: 'L1', result: 'NA', reason }),
91
+ ]
92
+ }
93
+
94
+ function makeNotRun(reason, skip = []) {
95
+ return ['DSC-04', 'DSC-05', 'DSC-06', 'DSC-07', 'DSC-08', 'DSC-09'].filter(id => !skip.includes(id)).map(id => {
96
+ const clause = { 'DSC-04': 'C-4', 'DSC-05': 'C-5', 'DSC-06': 'C-6', 'DSC-07': 'C-7', 'DSC-08': 'C-8', 'DSC-09': 'C-9' }[id]
97
+ const layer = { 'DSC-04': 'L1', 'DSC-05': 'L1', 'DSC-06': 'L1+L2', 'DSC-07': 'L2', 'DSC-08': 'L3', 'DSC-09': 'L1' }[id]
98
+ return check({ id, clause, layer, result: 'ERROR', coverage: 'not-run', reason })
99
+ })
100
+ }
101
+
102
+ /**
103
+ * Run L1-L3 in a child-free context. This is a best-effort harness: any
104
+ * failure that cannot be attributed to the plugin (missing host packages,
105
+ * incomplete stubs) is reported as ERROR/blocked, never FAIL.
106
+ *
107
+ * @param {object} input
108
+ * @param {string} input.dir
109
+ * @param {object} input.manifest
110
+ * @param {string} input.kind
111
+ * @param {object|null} input.entry
112
+ * @param {string} input.runtimePath
113
+ * @param {number} [input.timeoutMs]
114
+ * @param {object[]} [input.sources]
115
+ */
116
+ export async function runRuntimeChecks(input) {
117
+ const { dir, manifest, kind, entry, runtimePath, timeoutMs = 10_000, sources = [] } = input
118
+ if (kind !== 'plugin') return makeNa(`kind=${kind}; L1-L3 apply to plugin entries only`)
119
+ if (!runtimePath) return makeNotRun('no --runtime supplied; L1-L3 not run')
120
+ if (!entry?.exists) return makeNotRun('no resolvable runtime entry; L1-L3 not run')
121
+
122
+ let Context
123
+ try {
124
+ const runtime = await import(pathToFileURL(path.resolve(runtimePath)).href)
125
+ Context = runtime.Context ?? runtime.default?.Context
126
+ if (typeof Context !== 'function') throw new Error('runtime module does not export Context')
127
+ } catch (error) {
128
+ return makeNotRun(`runtime import failed: ${error.message}`)
129
+ }
130
+
131
+ let module
132
+ try {
133
+ module = await import(pathToFileURL(entry.absolute).href)
134
+ } catch (error) {
135
+ const message = error?.message ?? String(error)
136
+ if (classifyImportError(message) === 'blocked') {
137
+ return makeNotRun(`plugin import blocked by missing host dependency: ${message.split('\n')[0]}`)
138
+ }
139
+ return [
140
+ check({ id: 'DSC-04', clause: 'C-4', layer: 'L1', result: 'FAIL', reason: `entry import failed: ${message}`, evidence: [entry.relative] }),
141
+ ...makeNotRun('C-4 failed; L1-L3 short-circuited', ['DSC-04']),
142
+ ]
143
+ }
144
+
145
+ const plugin = pluginOf(module)
146
+ if (!plugin) return makeNa('entry does not export a function, { apply }, or default class')
147
+
148
+ let ctx
149
+ let fiber
150
+ let beforeStore = null
151
+ let beforeRegistry = 0
152
+ try {
153
+ ctx = new Context()
154
+ for (const name of declaredInject(module)) {
155
+ if (ctx.get(name, false) === undefined) {
156
+ try { ctx.provide(name, makeStub()) } catch { /* already provided by the root */ }
157
+ }
158
+ }
159
+ beforeStore = new Set(Reflect.ownKeys(ctx.reflect.store ?? {}))
160
+ beforeRegistry = ctx.registry.size
161
+ fiber = ctx.plugin(plugin)
162
+ await withTimeout(Promise.resolve(fiber), timeoutMs, 'plugin load')
163
+ } catch (error) {
164
+ const message = error?.message ?? String(error)
165
+ const stubGap = /is not a function|Cannot read propert(?:y|ies) of (?:undefined|null)/.test(message)
166
+ const blocked = classifyImportError(message) === 'blocked' || stubGap
167
+ return [
168
+ check({ id: 'DSC-04', clause: 'C-4', layer: 'L1', result: blocked ? 'ERROR' : 'FAIL', coverage: blocked ? 'not-run' : 'full', reason: `load failed: ${message}`, evidence: [entry.relative] }),
169
+ ...makeNotRun('C-4 did not reach ACTIVE', ['DSC-04']),
170
+ ]
171
+ }
172
+
173
+ const active = fiber.state === ACTIVE
174
+ const loadCheck = check({
175
+ id: 'DSC-04', clause: 'C-4', layer: 'L1',
176
+ result: active ? 'PASS' : 'FAIL',
177
+ reason: active ? '' : `fiber state ${fiber.state} is not ACTIVE (2)`,
178
+ evidence: [entry.relative],
179
+ })
180
+ if (!active) return [loadCheck, ...makeNotRun('C-4 did not reach ACTIVE', ['DSC-04'])]
181
+
182
+ const realFiber = fiber.ctx?.fiber ?? Object.getPrototypeOf(fiber) ?? fiber
183
+ const declared = declaredProvide(module)
184
+ const measured = serviceNames(ctx, realFiber)
185
+ const provideDetails = []
186
+ for (const name of declared) {
187
+ const impl = ownedService(ctx, realFiber, name)
188
+ if (impl) provideDetails.push(detail('C-5.2', 'PASS', `service ${name} is owned by the plugin fiber`))
189
+ else if (storeEntries(ctx).some(entry => entry?.name === name)) provideDetails.push(detail('C-5.2', 'FAIL', `service ${name} is owned by another fiber`))
190
+ else provideDetails.push(detail('C-5.2', 'FAIL', `declared provide ${name} was not registered`))
191
+ }
192
+ if (declared.length === 0) provideDetails.push(detail('C-5.1', 'NA', 'no provide declaration'))
193
+ const provideResult = declared.length === 0 ? 'NA' : worst(provideDetails.map(item => item.result))
194
+ const provideCheck = check({
195
+ id: 'DSC-05', clause: 'C-5', layer: 'L1', result: provideResult,
196
+ reason: provideDetails.filter(item => item.result !== 'PASS' && item.result !== 'NA').map(item => `${item.item}: ${item.reason}`).join(' | '),
197
+ details: provideDetails,
198
+ })
199
+
200
+ const effects = fiber.getEffects()
201
+ const labels = effects.map(effect => effect?.label ?? 'anonymous')
202
+ const privateHits = regexHits(sources, /(?:ctx\.events\._hooks|ctx\.reflect\.store|ctx\.registry\._internal|fiber\._disposables)\s*(?:\.|\[)[^\n]{0,40}=/g)
203
+ const effectDetails = []
204
+ if (effects.length === 0 && (declared.length > 0 || labels.length === 0)) effectDetails.push(detail('C-6.2', 'WARN', 'no registered effects observed'))
205
+ else effectDetails.push(detail('C-6.2', 'PASS', `${effects.length} registered effect(s)`))
206
+ if (labels.length > 0 && labels.every(label => label === 'anonymous')) effectDetails.push(detail('C-6.2', 'WARN', 'all effects are anonymous'))
207
+ if (privateHits.length > 0) effectDetails.push(detail('C-6.1', 'FAIL', 'direct write to a Cordis private registration surface', privateHits.slice(0, 3).map(hit => `${hit.file}:${hit.line}`)))
208
+ const effectResult = worst(effectDetails.map(item => item.result))
209
+ const effectCheck = check({
210
+ id: 'DSC-06', clause: 'C-6', layer: 'L1+L2', result: effectResult,
211
+ reason: effectDetails.filter(item => item.result !== 'PASS').map(item => `${item.item}: ${item.reason}`).join(' | '),
212
+ details: effectDetails,
213
+ })
214
+
215
+ let unloadDetails = []
216
+ let unloadResult = 'PASS'
217
+ try {
218
+ await withTimeout(Promise.resolve(fiber.dispose()), timeoutMs, 'fiber dispose')
219
+ const afterEffects = fiber.getEffects().length
220
+ const afterStore = new Set(Reflect.ownKeys(ctx.reflect.store ?? {}))
221
+ const afterRegistry = ctx.registry.size
222
+ unloadDetails = [
223
+ detail('C-7.1', afterEffects === 0 ? 'PASS' : 'FAIL', `getEffects().length=${afterEffects}`),
224
+ detail('C-7.2', [...beforeStore].every(key => afterStore.has(key)) ? 'PASS' : 'FAIL', 'service store returned to baseline'),
225
+ detail('C-7.3', beforeRegistry === afterRegistry ? 'PASS' : 'FAIL', `registry.size ${beforeRegistry} -> ${afterRegistry}`),
226
+ ]
227
+ unloadResult = worst(unloadDetails.map(item => item.result))
228
+ } catch (error) {
229
+ unloadDetails = [detail('C-7.5', 'FAIL', `dispose failed: ${error.message}`)]
230
+ unloadResult = 'FAIL'
231
+ }
232
+ const unloadCheck = check({
233
+ id: 'DSC-07', clause: 'C-7', layer: 'L2', result: unloadResult,
234
+ reason: unloadDetails.filter(item => item.result !== 'PASS').map(item => `${item.item}: ${item.reason}`).join(' | '),
235
+ details: unloadDetails,
236
+ })
237
+
238
+ let restartResult = 'PASS'
239
+ let restartDetails = []
240
+ try {
241
+ const ctxR = new Context()
242
+ for (const name of declaredInject(module)) {
243
+ if (ctxR.get(name, false) === undefined) {
244
+ try { ctxR.provide(name, makeStub()) } catch { /* ignore */ }
245
+ }
246
+ }
247
+ const fiberR = ctxR.plugin(plugin)
248
+ await withTimeout(Promise.resolve(fiberR), timeoutMs, 'restart fixture load')
249
+ const beforeRestart = fiberR.getEffects().length
250
+ const revisionBefore = typeof fiberR._revision === 'number' ? fiberR._revision : null
251
+ const epochBefore = fiberR._runner?.epoch
252
+ await withTimeout(Promise.resolve(fiberR.restart()), timeoutMs, 'fiber restart')
253
+ const revisionAfter = typeof fiberR._revision === 'number' ? fiberR._revision : null
254
+ const epochAfter = fiberR._runner?.epoch
255
+ const afterRestart = fiberR.getEffects()
256
+ restartDetails.push(detail('C-8.1', fiberR.state === ACTIVE ? 'PASS' : 'FAIL', `state after restart=${fiberR.state}`))
257
+ restartDetails.push(detail('C-8.1', afterRestart.length === beforeRestart ? 'PASS' : 'WARN', `effect count ${beforeRestart} -> ${afterRestart.length}`))
258
+ const advanced = (revisionBefore !== null && revisionAfter !== null && revisionAfter > revisionBefore) || (epochBefore !== undefined && epochAfter !== undefined && epochAfter !== epochBefore)
259
+ restartDetails.push(detail('C-8.2', advanced ? 'PASS' : 'WARN', `revision ${revisionBefore} -> ${revisionAfter}; epoch ${String(epochBefore)} -> ${String(epochAfter)}`))
260
+ const ctx2 = new Context()
261
+ for (const name of declaredInject(module)) {
262
+ if (ctx2.get(name, false) === undefined) {
263
+ try { ctx2.provide(name, makeStub()) } catch { /* ignore */ }
264
+ }
265
+ }
266
+ const fiber2 = ctx2.plugin(plugin)
267
+ await withTimeout(Promise.resolve(fiber2), timeoutMs, 'cold remount')
268
+ restartDetails.push(detail('C-8.3', fiber2.state === ACTIVE ? 'PASS' : 'FAIL', `cold remount state=${fiber2.state}`))
269
+ } catch (error) {
270
+ restartDetails.push(detail('C-8.1', 'FAIL', `restart/remount failed: ${error.message}`))
271
+ }
272
+ restartResult = worst(restartDetails.map(item => item.result))
273
+ const restartCheck = check({
274
+ id: 'DSC-08', clause: 'C-8', layer: 'L3', result: restartResult,
275
+ reason: restartDetails.filter(item => item.result !== 'PASS').map(item => `${item.item}: ${item.reason}`).join(' | '),
276
+ details: restartDetails,
277
+ })
278
+
279
+ const crossDetails = []
280
+ for (const name of declared) {
281
+ if (!measured.has(name)) crossDetails.push(detail('C-9.1', 'FAIL', `declared provide ${name} is missing from measured services`))
282
+ }
283
+ for (const name of measured) {
284
+ if (!declared.includes(name)) crossDetails.push(detail('C-9.2', 'WARN', `measured service ${name} is not declared`))
285
+ }
286
+ const inject = declaredInject(module)
287
+ for (const name of inject) {
288
+ const hits = regexHits(sources, new RegExp(`ctx\\.${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g'))
289
+ if (hits.length === 0) crossDetails.push(detail('C-9.3', 'WARN', `inject declares ${name} but no static ctx.${name} read was found`))
290
+ }
291
+ const crossResult = crossDetails.length === 0 ? 'PASS' : worst(crossDetails.map(item => item.result))
292
+ const crossCheck = check({
293
+ id: 'DSC-09', clause: 'C-9', layer: 'L1', result: crossResult,
294
+ reason: crossDetails.filter(item => item.result !== 'PASS').map(item => `${item.item}: ${item.reason}`).join(' | '),
295
+ details: crossDetails,
296
+ })
297
+
298
+ return [loadCheck, provideCheck, effectCheck, unloadCheck, restartCheck, crossCheck]
299
+ }