@nebulacomponents/citable 1.19.0 → 1.20.0

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 (239) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +12 -1
  3. package/dist/universal/.agents/skills/citable/SKILL.md +11 -2
  4. package/dist/universal/.agents/skills/citable/VERSION +1 -1
  5. package/dist/universal/.agents/skills/citable/manifest.json +15 -12
  6. package/dist/universal/.agents/skills/citable/schemas/action-plan.schema.json +38 -1
  7. package/dist/universal/.agents/skills/citable/schemas/audit-coverage.schema.json +118 -0
  8. package/dist/universal/.agents/skills/citable/schemas/collection-result.schema.json +35 -0
  9. package/dist/universal/.agents/skills/citable/schemas/config.schema.json +2 -0
  10. package/dist/universal/.agents/skills/citable/schemas/cro-report.schema.json +8 -1
  11. package/dist/universal/.agents/skills/citable/schemas/finding.schema.json +105 -2
  12. package/dist/universal/.agents/skills/citable/schemas/remediation-verification.schema.json +12 -1
  13. package/dist/universal/.agents/skills/citable/schemas/run.schema.json +15 -2
  14. package/dist/universal/.agents/skills/citable/schemas/search-report.schema.json +8 -1
  15. package/dist/universal/.agents/skills/citable/schemas/summary.schema.json +41 -0
  16. package/dist/universal/.claude/agents/citable/manifest.json +2 -2
  17. package/dist/universal/.claude/skills/citable/SKILL.md +11 -2
  18. package/dist/universal/.claude/skills/citable/VERSION +1 -1
  19. package/dist/universal/.claude/skills/citable/manifest.json +15 -12
  20. package/dist/universal/.claude/skills/citable/schemas/action-plan.schema.json +38 -1
  21. package/dist/universal/.claude/skills/citable/schemas/audit-coverage.schema.json +118 -0
  22. package/dist/universal/.claude/skills/citable/schemas/collection-result.schema.json +35 -0
  23. package/dist/universal/.claude/skills/citable/schemas/config.schema.json +2 -0
  24. package/dist/universal/.claude/skills/citable/schemas/cro-report.schema.json +8 -1
  25. package/dist/universal/.claude/skills/citable/schemas/finding.schema.json +105 -2
  26. package/dist/universal/.claude/skills/citable/schemas/remediation-verification.schema.json +12 -1
  27. package/dist/universal/.claude/skills/citable/schemas/run.schema.json +15 -2
  28. package/dist/universal/.claude/skills/citable/schemas/search-report.schema.json +8 -1
  29. package/dist/universal/.claude/skills/citable/schemas/summary.schema.json +41 -0
  30. package/dist/universal/.cursor/skills/citable/SKILL.md +11 -2
  31. package/dist/universal/.cursor/skills/citable/VERSION +1 -1
  32. package/dist/universal/.cursor/skills/citable/manifest.json +15 -12
  33. package/dist/universal/.cursor/skills/citable/schemas/action-plan.schema.json +38 -1
  34. package/dist/universal/.cursor/skills/citable/schemas/audit-coverage.schema.json +118 -0
  35. package/dist/universal/.cursor/skills/citable/schemas/collection-result.schema.json +35 -0
  36. package/dist/universal/.cursor/skills/citable/schemas/config.schema.json +2 -0
  37. package/dist/universal/.cursor/skills/citable/schemas/cro-report.schema.json +8 -1
  38. package/dist/universal/.cursor/skills/citable/schemas/finding.schema.json +105 -2
  39. package/dist/universal/.cursor/skills/citable/schemas/remediation-verification.schema.json +12 -1
  40. package/dist/universal/.cursor/skills/citable/schemas/run.schema.json +15 -2
  41. package/dist/universal/.cursor/skills/citable/schemas/search-report.schema.json +8 -1
  42. package/dist/universal/.cursor/skills/citable/schemas/summary.schema.json +41 -0
  43. package/dist/universal/.gemini/skills/citable/SKILL.md +11 -2
  44. package/dist/universal/.gemini/skills/citable/VERSION +1 -1
  45. package/dist/universal/.gemini/skills/citable/manifest.json +15 -12
  46. package/dist/universal/.gemini/skills/citable/schemas/action-plan.schema.json +38 -1
  47. package/dist/universal/.gemini/skills/citable/schemas/audit-coverage.schema.json +118 -0
  48. package/dist/universal/.gemini/skills/citable/schemas/collection-result.schema.json +35 -0
  49. package/dist/universal/.gemini/skills/citable/schemas/config.schema.json +2 -0
  50. package/dist/universal/.gemini/skills/citable/schemas/cro-report.schema.json +8 -1
  51. package/dist/universal/.gemini/skills/citable/schemas/finding.schema.json +105 -2
  52. package/dist/universal/.gemini/skills/citable/schemas/remediation-verification.schema.json +12 -1
  53. package/dist/universal/.gemini/skills/citable/schemas/run.schema.json +15 -2
  54. package/dist/universal/.gemini/skills/citable/schemas/search-report.schema.json +8 -1
  55. package/dist/universal/.gemini/skills/citable/schemas/summary.schema.json +41 -0
  56. package/dist/universal/.github/skills/citable/SKILL.md +11 -2
  57. package/dist/universal/.github/skills/citable/VERSION +1 -1
  58. package/dist/universal/.github/skills/citable/manifest.json +15 -12
  59. package/dist/universal/.github/skills/citable/schemas/action-plan.schema.json +38 -1
  60. package/dist/universal/.github/skills/citable/schemas/audit-coverage.schema.json +118 -0
  61. package/dist/universal/.github/skills/citable/schemas/collection-result.schema.json +35 -0
  62. package/dist/universal/.github/skills/citable/schemas/config.schema.json +2 -0
  63. package/dist/universal/.github/skills/citable/schemas/cro-report.schema.json +8 -1
  64. package/dist/universal/.github/skills/citable/schemas/finding.schema.json +105 -2
  65. package/dist/universal/.github/skills/citable/schemas/remediation-verification.schema.json +12 -1
  66. package/dist/universal/.github/skills/citable/schemas/run.schema.json +15 -2
  67. package/dist/universal/.github/skills/citable/schemas/search-report.schema.json +8 -1
  68. package/dist/universal/.github/skills/citable/schemas/summary.schema.json +41 -0
  69. package/dist/universal/.kiro/skills/citable/SKILL.md +11 -2
  70. package/dist/universal/.kiro/skills/citable/VERSION +1 -1
  71. package/dist/universal/.kiro/skills/citable/manifest.json +15 -12
  72. package/dist/universal/.kiro/skills/citable/schemas/action-plan.schema.json +38 -1
  73. package/dist/universal/.kiro/skills/citable/schemas/audit-coverage.schema.json +118 -0
  74. package/dist/universal/.kiro/skills/citable/schemas/collection-result.schema.json +35 -0
  75. package/dist/universal/.kiro/skills/citable/schemas/config.schema.json +2 -0
  76. package/dist/universal/.kiro/skills/citable/schemas/cro-report.schema.json +8 -1
  77. package/dist/universal/.kiro/skills/citable/schemas/finding.schema.json +105 -2
  78. package/dist/universal/.kiro/skills/citable/schemas/remediation-verification.schema.json +12 -1
  79. package/dist/universal/.kiro/skills/citable/schemas/run.schema.json +15 -2
  80. package/dist/universal/.kiro/skills/citable/schemas/search-report.schema.json +8 -1
  81. package/dist/universal/.kiro/skills/citable/schemas/summary.schema.json +41 -0
  82. package/dist/universal/.opencode/skills/citable/SKILL.md +11 -2
  83. package/dist/universal/.opencode/skills/citable/VERSION +1 -1
  84. package/dist/universal/.opencode/skills/citable/manifest.json +15 -12
  85. package/dist/universal/.opencode/skills/citable/schemas/action-plan.schema.json +38 -1
  86. package/dist/universal/.opencode/skills/citable/schemas/audit-coverage.schema.json +118 -0
  87. package/dist/universal/.opencode/skills/citable/schemas/collection-result.schema.json +35 -0
  88. package/dist/universal/.opencode/skills/citable/schemas/config.schema.json +2 -0
  89. package/dist/universal/.opencode/skills/citable/schemas/cro-report.schema.json +8 -1
  90. package/dist/universal/.opencode/skills/citable/schemas/finding.schema.json +105 -2
  91. package/dist/universal/.opencode/skills/citable/schemas/remediation-verification.schema.json +12 -1
  92. package/dist/universal/.opencode/skills/citable/schemas/run.schema.json +15 -2
  93. package/dist/universal/.opencode/skills/citable/schemas/search-report.schema.json +8 -1
  94. package/dist/universal/.opencode/skills/citable/schemas/summary.schema.json +41 -0
  95. package/dist/universal/.pi/agent/skills/citable/SKILL.md +11 -2
  96. package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
  97. package/dist/universal/.pi/agent/skills/citable/manifest.json +15 -12
  98. package/dist/universal/.pi/agent/skills/citable/schemas/action-plan.schema.json +38 -1
  99. package/dist/universal/.pi/agent/skills/citable/schemas/audit-coverage.schema.json +118 -0
  100. package/dist/universal/.pi/agent/skills/citable/schemas/collection-result.schema.json +35 -0
  101. package/dist/universal/.pi/agent/skills/citable/schemas/config.schema.json +2 -0
  102. package/dist/universal/.pi/agent/skills/citable/schemas/cro-report.schema.json +8 -1
  103. package/dist/universal/.pi/agent/skills/citable/schemas/finding.schema.json +105 -2
  104. package/dist/universal/.pi/agent/skills/citable/schemas/remediation-verification.schema.json +12 -1
  105. package/dist/universal/.pi/agent/skills/citable/schemas/run.schema.json +15 -2
  106. package/dist/universal/.pi/agent/skills/citable/schemas/search-report.schema.json +8 -1
  107. package/dist/universal/.pi/agent/skills/citable/schemas/summary.schema.json +41 -0
  108. package/dist/universal/.qoder/skills/citable/SKILL.md +11 -2
  109. package/dist/universal/.qoder/skills/citable/VERSION +1 -1
  110. package/dist/universal/.qoder/skills/citable/manifest.json +15 -12
  111. package/dist/universal/.qoder/skills/citable/schemas/action-plan.schema.json +38 -1
  112. package/dist/universal/.qoder/skills/citable/schemas/audit-coverage.schema.json +118 -0
  113. package/dist/universal/.qoder/skills/citable/schemas/collection-result.schema.json +35 -0
  114. package/dist/universal/.qoder/skills/citable/schemas/config.schema.json +2 -0
  115. package/dist/universal/.qoder/skills/citable/schemas/cro-report.schema.json +8 -1
  116. package/dist/universal/.qoder/skills/citable/schemas/finding.schema.json +105 -2
  117. package/dist/universal/.qoder/skills/citable/schemas/remediation-verification.schema.json +12 -1
  118. package/dist/universal/.qoder/skills/citable/schemas/run.schema.json +15 -2
  119. package/dist/universal/.qoder/skills/citable/schemas/search-report.schema.json +8 -1
  120. package/dist/universal/.qoder/skills/citable/schemas/summary.schema.json +41 -0
  121. package/dist/universal/.rovodev/skills/citable/SKILL.md +11 -2
  122. package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
  123. package/dist/universal/.rovodev/skills/citable/manifest.json +15 -12
  124. package/dist/universal/.rovodev/skills/citable/schemas/action-plan.schema.json +38 -1
  125. package/dist/universal/.rovodev/skills/citable/schemas/audit-coverage.schema.json +118 -0
  126. package/dist/universal/.rovodev/skills/citable/schemas/collection-result.schema.json +35 -0
  127. package/dist/universal/.rovodev/skills/citable/schemas/config.schema.json +2 -0
  128. package/dist/universal/.rovodev/skills/citable/schemas/cro-report.schema.json +8 -1
  129. package/dist/universal/.rovodev/skills/citable/schemas/finding.schema.json +105 -2
  130. package/dist/universal/.rovodev/skills/citable/schemas/remediation-verification.schema.json +12 -1
  131. package/dist/universal/.rovodev/skills/citable/schemas/run.schema.json +15 -2
  132. package/dist/universal/.rovodev/skills/citable/schemas/search-report.schema.json +8 -1
  133. package/dist/universal/.rovodev/skills/citable/schemas/summary.schema.json +41 -0
  134. package/dist/universal/.trae/skills/citable/SKILL.md +11 -2
  135. package/dist/universal/.trae/skills/citable/VERSION +1 -1
  136. package/dist/universal/.trae/skills/citable/manifest.json +15 -12
  137. package/dist/universal/.trae/skills/citable/schemas/action-plan.schema.json +38 -1
  138. package/dist/universal/.trae/skills/citable/schemas/audit-coverage.schema.json +118 -0
  139. package/dist/universal/.trae/skills/citable/schemas/collection-result.schema.json +35 -0
  140. package/dist/universal/.trae/skills/citable/schemas/config.schema.json +2 -0
  141. package/dist/universal/.trae/skills/citable/schemas/cro-report.schema.json +8 -1
  142. package/dist/universal/.trae/skills/citable/schemas/finding.schema.json +105 -2
  143. package/dist/universal/.trae/skills/citable/schemas/remediation-verification.schema.json +12 -1
  144. package/dist/universal/.trae/skills/citable/schemas/run.schema.json +15 -2
  145. package/dist/universal/.trae/skills/citable/schemas/search-report.schema.json +8 -1
  146. package/dist/universal/.trae/skills/citable/schemas/summary.schema.json +41 -0
  147. package/dist/universal/.trae-cn/skills/citable/SKILL.md +11 -2
  148. package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
  149. package/dist/universal/.trae-cn/skills/citable/manifest.json +15 -12
  150. package/dist/universal/.trae-cn/skills/citable/schemas/action-plan.schema.json +38 -1
  151. package/dist/universal/.trae-cn/skills/citable/schemas/audit-coverage.schema.json +118 -0
  152. package/dist/universal/.trae-cn/skills/citable/schemas/collection-result.schema.json +35 -0
  153. package/dist/universal/.trae-cn/skills/citable/schemas/config.schema.json +2 -0
  154. package/dist/universal/.trae-cn/skills/citable/schemas/cro-report.schema.json +8 -1
  155. package/dist/universal/.trae-cn/skills/citable/schemas/finding.schema.json +105 -2
  156. package/dist/universal/.trae-cn/skills/citable/schemas/remediation-verification.schema.json +12 -1
  157. package/dist/universal/.trae-cn/skills/citable/schemas/run.schema.json +15 -2
  158. package/dist/universal/.trae-cn/skills/citable/schemas/search-report.schema.json +8 -1
  159. package/dist/universal/.trae-cn/skills/citable/schemas/summary.schema.json +41 -0
  160. package/dist/universal/manifest.json +184 -148
  161. package/package.json +1 -1
  162. package/schemas/action-plan.schema.json +38 -1
  163. package/schemas/audit-coverage.schema.json +118 -0
  164. package/schemas/collection-result.schema.json +35 -0
  165. package/schemas/config.schema.json +2 -0
  166. package/schemas/cro-report.schema.json +8 -1
  167. package/schemas/finding.schema.json +105 -2
  168. package/schemas/remediation-verification.schema.json +12 -1
  169. package/schemas/run.schema.json +15 -2
  170. package/schemas/search-report.schema.json +8 -1
  171. package/schemas/summary.schema.json +41 -0
  172. package/skill/SKILL.md +11 -2
  173. package/src/acceptance/reproducibility.js +5 -25
  174. package/src/analysis/croRoadmap.js +3 -3
  175. package/src/analysis/strategicRoadmap.js +3 -3
  176. package/src/artifacts/interchange.js +2 -6
  177. package/src/cli/index.js +15 -3
  178. package/src/commands/actionPlan.js +46 -5
  179. package/src/commands/applyRemediation.js +5 -5
  180. package/src/commands/audit.js +75 -8
  181. package/src/commands/ciWorkflow.js +7 -2
  182. package/src/commands/compareSnapshots.js +155 -47
  183. package/src/commands/connect.js +24 -4
  184. package/src/commands/context.js +41 -2
  185. package/src/commands/croSuite.js +51 -13
  186. package/src/commands/delivery.js +7 -7
  187. package/src/commands/governance.js +9 -10
  188. package/src/commands/implementationKit.js +2 -1
  189. package/src/commands/init.js +8 -1
  190. package/src/commands/previewCro.js +1 -1
  191. package/src/commands/prioritize.js +3 -2
  192. package/src/commands/reviews.js +31 -5
  193. package/src/commands/roadmapCmd.js +7 -13
  194. package/src/commands/sweep.js +15 -2
  195. package/src/commands/verifyRemediation.js +94 -19
  196. package/src/connectors/collectionResult.js +80 -0
  197. package/src/connectors/ga4.js +46 -11
  198. package/src/connectors/gsc.js +41 -6
  199. package/src/connectors/index.js +1 -1
  200. package/src/connectors/webflow.js +140 -10
  201. package/src/connectors/wordpress.js +92 -8
  202. package/src/crawler/fetch.js +220 -24
  203. package/src/crawler/resourceValidity.js +119 -0
  204. package/src/crawler/sitemap.js +435 -16
  205. package/src/crawler/sitemapCollector.js +364 -0
  206. package/src/crawler/urlIdentity.js +97 -0
  207. package/src/detectors/agent.js +11 -0
  208. package/src/detectors/ans.js +16 -1
  209. package/src/detectors/arch.js +6 -0
  210. package/src/detectors/claim.js +9 -0
  211. package/src/detectors/crawl.js +7 -0
  212. package/src/detectors/cro.js +21 -0
  213. package/src/detectors/cwv.js +4 -0
  214. package/src/detectors/entity.js +7 -0
  215. package/src/detectors/evd.js +9 -0
  216. package/src/detectors/framework.js +28 -2
  217. package/src/detectors/geoReco.js +16 -0
  218. package/src/detectors/hreflang.js +3 -0
  219. package/src/detectors/lifeMeas.js +9 -0
  220. package/src/detectors/link.js +6 -1
  221. package/src/detectors/page.js +11 -0
  222. package/src/detectors/schemaData.js +16 -1
  223. package/src/detectors/tech.js +34 -3
  224. package/src/evidence/coverage.js +448 -0
  225. package/src/evidence/determination.js +157 -0
  226. package/src/evidence/downstream.js +110 -0
  227. package/src/evidence/hashes.js +109 -0
  228. package/src/evidence/run.js +68 -8
  229. package/src/extractor/page.js +29 -2
  230. package/src/extractor/site.js +298 -50
  231. package/src/reporting/executiveCroReport.js +36 -13
  232. package/src/reporting/executiveExport.js +45 -16
  233. package/src/reporting/executiveSearchReport.js +56 -22
  234. package/src/reporting/report.js +56 -9
  235. package/src/shared/epistemicLanguage.js +86 -0
  236. package/src/shared/evidenceSourceResolver.js +48 -17
  237. package/src/shared/htmlEscape.js +8 -4
  238. package/src/shared/runPackageVerifier.js +99 -8
  239. package/src/shared/verifiedRunLoader.js +129 -0
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
5
5
  import { canonicalJson } from '../release/governance.js';
6
6
  import { nowIso, readJson, sha256, sha256File, writeJson } from '../shared/io.js';
7
7
  import { validateAgainst } from '../shared/schemaValidator.js';
8
+ import { verifyRunPackage } from '../shared/runPackageVerifier.js';
8
9
 
9
10
  const PACKAGE = readJson(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../package.json'));
10
11
  const ENVELOPE = 'citable-artifact.json';
@@ -37,18 +38,13 @@ function validateRunPackage(runDir) {
37
38
  const checksumsFile = path.join(runDir, 'checksums.json');
38
39
  if (!fs.existsSync(manifestFile) || !fs.existsSync(checksumsFile)) throw new Error('run package requires manifest.json and checksums.json');
39
40
  const manifest = readJson(manifestFile);
40
- const manifestCheck = validateAgainst('run.schema.json', manifest);
41
- if (!manifestCheck.valid) throw new Error(`run manifest invalid: ${manifestCheck.errors.join('; ')}`);
42
41
  const checksums = readJson(checksumsFile);
43
- if (!checksums || Array.isArray(checksums) || typeof checksums !== 'object') throw new Error('checksums.json must be an object');
42
+ verifyRunPackage(runDir, { requireFindings: false, requireCompleted: false, requireChecksums: true, ignoredFiles: [ENVELOPE] });
44
43
  const actualFiles = filesUnder(runDir).filter((name) => name !== ENVELOPE);
45
44
  const expectedFiles = [...Object.keys(checksums), 'checksums.json'].sort();
46
45
  if (new Set(expectedFiles).size !== expectedFiles.length) throw new Error('checksums.json contains duplicate package paths');
47
46
  for (const rel of Object.keys(checksums)) {
48
47
  if (!safeRelativePath(rel) || rel === ENVELOPE || rel === 'checksums.json') throw new Error(`unsafe checksum path: ${rel}`);
49
- const file = path.join(runDir, ...rel.split('/'));
50
- if (!fs.existsSync(file) || !fs.statSync(file).isFile()) throw new Error(`checksummed artifact is missing: ${rel}`);
51
- if (!/^[a-f0-9]{64}$/.test(checksums[rel]) || sha256File(file) !== checksums[rel]) throw new Error(`artifact checksum mismatch: ${rel}`);
52
48
  }
53
49
  const extras = actualFiles.filter((name) => !expectedFiles.includes(name));
54
50
  const missing = expectedFiles.filter((name) => !actualFiles.includes(name));
package/src/cli/index.js CHANGED
@@ -185,6 +185,8 @@ Commands
185
185
 
186
186
  Options
187
187
  --target <dir|url> Built output directory or deployed URL to audit
188
+ --max-pages <count> Maximum unique page URLs attempted for a URL audit (default: 500)
189
+ --time-budget-seconds <seconds> Whole-run URL collection limit (default: 1800)
188
190
  --base-url <url> Base URL for path resolution of a built output dir
189
191
  --ref-date <YYYY-MM-DD> Reference date for expiry/staleness checks (default: today)
190
192
  --input <file> Import evidence, remediation, or browser plan
@@ -233,7 +235,7 @@ Options
233
235
  No output of this tool guarantees crawling, indexing, ranking, citation,
234
236
  recommendation, inclusion, sentiment, or conversion outcomes.`;
235
237
 
236
- function parseArgs(argv) {
238
+ export function parseArgs(argv) {
237
239
  const args = { _: [] };
238
240
  for (let i = 0; i < argv.length; i++) {
239
241
  const a = argv[i];
@@ -243,6 +245,8 @@ function parseArgs(argv) {
243
245
  else if (a === '--interactions') args.interactions = true;
244
246
  else if (a === '--lighthouse') args.lighthouse = true;
245
247
  else if (a === '--target') args.target = argv[++i];
248
+ else if (a === '--max-pages') args.maxPages = Number(argv[++i]);
249
+ else if (a === '--time-budget-seconds') args.timeBudgetSeconds = Number(argv[++i]);
246
250
  else if (a === '--base-url') args.baseUrl = argv[++i];
247
251
  else if (a === '--ref-date') args.refDate = argv[++i];
248
252
  else if (a === '--viewport') args.viewport = argv[++i];
@@ -411,8 +415,16 @@ ${r.findings.map((f) => ` [${f.rule_id}] (${f.severity}) ${f.summary}\n Fix:
411
415
  break;
412
416
  }
413
417
  const scope = args._[0];
414
- const r = await audit(root, { target: args.target, scope, baseUrl: args.baseUrl, refDate: args.refDate, viewport: args.viewport });
415
- out(args, `Audit ${r.runId}: ${r.summary.total} finding(s) [${Object.entries(r.summary.by_severity).map(([k, v]) => `${k}:${v}`).join(' ')}]\nEvidence package: ${r.dir}\nReport: ${path.join(r.dir, 'report.md')}\nStatus: ${r.manifest.status}${r.manifest.incomplete_checks.length ? `\nIncomplete: ${r.manifest.incomplete_checks.join('; ')}` : ''}`, { runId: r.runId, dir: r.dir, summary: r.summary, status: r.manifest.status });
418
+ const r = await audit(root, {
419
+ target: args.target,
420
+ scope,
421
+ baseUrl: args.baseUrl,
422
+ refDate: args.refDate,
423
+ viewport: args.viewport,
424
+ maxPages: args.maxPages,
425
+ timeBudgetSeconds: args.timeBudgetSeconds,
426
+ });
427
+ out(args, `Audit ${r.runId}: ${r.summary.total} finding(s) [${Object.entries(r.summary.by_severity).map(([k, v]) => `${k}:${v}`).join(' ')}]\nEvidence package: ${r.dir}\nReport: ${path.join(r.dir, 'report.md')}\nStatus: ${r.manifest.status}\nExecution: ${r.manifest.execution_status}\nCoverage: ${r.manifest.coverage_status}\nDetermination: ${r.manifest.determination_status}${r.manifest.incomplete_checks.length ? `\nIncomplete: ${r.manifest.incomplete_checks.join('; ')}` : ''}`, { runId: r.runId, dir: r.dir, summary: r.summary, status: r.manifest.status, execution_status: r.manifest.execution_status, coverage_status: r.manifest.coverage_status, determination_status: r.manifest.determination_status });
416
428
  break;
417
429
  }
418
430
  case 'plan-audit': {
@@ -2,7 +2,10 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { readJson, writeJson, sha256, nowIso } from '../shared/io.js';
4
4
  import { validateAgainst } from '../shared/schemaValidator.js';
5
+ import { downstreamEnvelope } from '../evidence/downstream.js';
6
+ import { REQUIREMENTS, evaluateRequirement } from '../evidence/determination.js';
5
7
  import { checklistItem, toStringArray } from '../shared/checklist.js';
8
+ import { loadVerifiedRun } from '../shared/verifiedRunLoader.js';
6
9
 
7
10
  const SEVERITY = { critical: 0, high: 1, medium: 2, low: 3, informational: 3, experimental: 3 };
8
11
  const PHASE = { unblock: 0, governance: 1, content: 2, optimization: 3 };
@@ -46,6 +49,7 @@ function renderPlan(plan) {
46
49
  '# Citable action plan', '',
47
50
  `- Source audit: \`${plan.source_run_id}\``,
48
51
  `- Actions: ${plan.summary.total_actions}; ready: ${plan.summary.ready}; blocked: ${plan.summary.blocked}`,
52
+ `- Evidence coverage: ${plan.source_coverage.coverage_status}; determination: ${plan.source_coverage.determination_status}`,
49
53
  '',
50
54
  '> This plan prioritizes observed findings. It does not guarantee ranking, citation, recommendation, or conversion outcomes.',
51
55
  '',
@@ -61,6 +65,7 @@ function renderPlan(plan) {
61
65
  lines.push(`- Decision owner: ${action.decision_owner ?? 'unassigned'}`);
62
66
  lines.push(`- Dependencies: ${action.depends_on_action_ids.length ? action.depends_on_action_ids.join(', ') : 'none established'}`);
63
67
  if (action.required_input.length) lines.push(`- Required input: ${action.required_input.join(', ')}`);
68
+ if (action.limitations.length) lines.push(`- Evidence limitations: ${action.limitations.join('; ')}`);
64
69
  if (action.semantic_gates.length) lines.push(`- Semantic gates: ${action.semantic_gates.join(', ')}`);
65
70
  lines.push(`- Failure condition: ${action.failure_condition}`);
66
71
  lines.push(`- Leading indicators: ${action.leading_indicators.length ? action.leading_indicators.join(', ') : 'none established'}`);
@@ -78,13 +83,37 @@ export function actionPlan(root, { runId } = {}) {
78
83
  const sourceDir = path.join(runsDir, sourceRunId);
79
84
  const findingsFile = path.join(sourceDir, 'findings.json');
80
85
  if (!fs.existsSync(findingsFile)) throw new Error(`run ${sourceRunId} has no findings.json`);
81
- const rawFindings = fs.readFileSync(findingsFile, 'utf8');
82
- const findings = JSON.parse(rawFindings);
83
- const manifest = readJson(path.join(sourceDir, 'manifest.json'));
86
+ const loaded = loadVerifiedRun(sourceDir, { requireCompletedExecution: false, allowLegacy: true, requireCoverage: false });
87
+ const findings = loaded.findings;
88
+ const rawFindings = JSON.stringify(findings);
89
+ const manifest = loaded.manifest;
90
+ const coverage = loaded.coverage;
91
+ const sourceCoverage = downstreamEnvelope(coverage, {
92
+ artifact: coverage ? 'coverage.json' : null,
93
+ });
84
94
  const target = manifest.target?.location;
85
95
  const actions = findings.map((finding) => {
86
96
  const owner = finding.remediation.owner ?? null;
87
- const blocked = finding.remediation.review_required && !owner;
97
+ const findingScope = finding.evidence_scope || {
98
+ requirement: 'evaluated_subset',
99
+ satisfaction: sourceCoverage.determination_status,
100
+ coverage_ref: coverage ? 'coverage.json' : null,
101
+ resource_ids: [],
102
+ };
103
+ const requirement = findingScope.requirement || REQUIREMENTS.EVALUATED_SUBSET;
104
+ let requirementResult;
105
+ try {
106
+ requirementResult = evaluateRequirement(requirement, coverage, finding.subject);
107
+ } catch (error) {
108
+ requirementResult = { status: 'indeterminate', reason: `invalid_evidence_requirement: ${error.message}` };
109
+ }
110
+ const scopeSatisfied = ['supported', 'qualified'].includes(requirementResult.status);
111
+ const effectiveDetermination = sourceCoverage.determination_status === 'indeterminate'
112
+ || sourceCoverage.determination_status === 'not_applicable'
113
+ || !scopeSatisfied
114
+ ? 'indeterminate'
115
+ : (sourceCoverage.determination_status === 'qualified' || requirementResult.status === 'qualified' ? 'qualified' : 'supported');
116
+ const blocked = (finding.remediation.review_required && !owner) || !scopeSatisfied || effectiveDetermination === 'indeterminate';
88
117
  const scope = finding.discipline.length === 1 && ['aeo', 'geo', 'seo'].includes(finding.discipline[0]) ? ` ${finding.discipline[0]}` : '';
89
118
  return {
90
119
  action_id: `ACT-${finding.finding_id.replace(/^F-/, '')}`,
@@ -97,7 +126,18 @@ export function actionPlan(root, { runId } = {}) {
97
126
  owner,
98
127
  decision_owner: owner,
99
128
  status: blocked ? 'blocked' : 'ready',
100
- required_input: blocked ? toStringArray([checklistItem('owner', 'accountable owner')]) : [],
129
+ evidence_scope: { ...findingScope, satisfaction: effectiveDetermination },
130
+ coverage_status: sourceCoverage.coverage_status,
131
+ determination_status: effectiveDetermination,
132
+ limitations: [
133
+ ...sourceCoverage.limitations,
134
+ ...(finding.reasoning?.limitations || []),
135
+ ...(requirementResult.reason ? [`evidence requirement: ${requirementResult.reason}`] : []),
136
+ ].filter((item, index, all) => all.indexOf(item) === index),
137
+ required_input: blocked ? toStringArray([
138
+ ...(finding.remediation.review_required && !owner ? [checklistItem('owner', 'accountable owner')] : []),
139
+ ...(!scopeSatisfied || effectiveDetermination === 'indeterminate' ? ['sufficient comparable evidence for the declared scope'] : []),
140
+ ]) : [],
101
141
  semantic_gates: semanticGates(finding),
102
142
  unsafe_shortcuts: finding.remediation.unsafe_shortcuts || [],
103
143
  failure_condition: `${finding.verification.detector_to_rerun} still reports the exact subject, or verification cannot complete with sufficient evidence`,
@@ -120,6 +160,7 @@ export function actionPlan(root, { runId } = {}) {
120
160
  ready: actions.filter((action) => action.status === 'ready').length,
121
161
  blocked: actions.filter((action) => action.status === 'blocked').length,
122
162
  },
163
+ source_coverage: sourceCoverage,
123
164
  actions,
124
165
  };
125
166
  const validation = validateAgainst('action-plan.schema.json', plan);
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { createRun } from '../evidence/run.js';
4
4
  import { readJson, sha256 } from '../shared/io.js';
5
5
  import { validateAgainst } from '../shared/schemaValidator.js';
6
+ import { loadVerifiedRun } from '../shared/verifiedRunLoader.js';
6
7
 
7
8
  function inside(root, candidate) {
8
9
  const base = path.resolve(root) + path.sep;
@@ -19,11 +20,10 @@ export function applyRemediation(root, { input, write = false } = {}) {
19
20
  const spec = readJson(input);
20
21
  const valid = validateAgainst('remediation-spec.schema.json', spec);
21
22
  if (!valid.valid) throw new Error(`remediation spec invalid: ${valid.errors.join('; ')}`);
22
- const manifestFile = path.join(root, '.citable', 'runs', spec.source_run_id, 'manifest.json');
23
- if (!fs.existsSync(manifestFile)) throw new Error(`source run not found: ${spec.source_run_id}`);
24
- const findingsFile = path.join(root, '.citable', 'runs', spec.source_run_id, 'findings.json');
25
- if (!fs.existsSync(findingsFile)) throw new Error(`source run has no findings: ${spec.source_run_id}`);
26
- const sourceFindingIds = new Set(readJson(findingsFile).map((finding) => finding.finding_id));
23
+ const sourceDir = path.join(root, '.citable', 'runs', spec.source_run_id);
24
+ if (!fs.existsSync(sourceDir)) throw new Error(`source run not found: ${spec.source_run_id}`);
25
+ const source = loadVerifiedRun(sourceDir, { requireCompletedExecution: false, allowLegacy: true, requireCoverage: false });
26
+ const sourceFindingIds = new Set(source.findings.map((finding) => finding.finding_id));
27
27
  const results = [];
28
28
  const pending = [];
29
29
  for (const operation of spec.operations) {
@@ -10,10 +10,16 @@ import { writeJson, sha256 } from '../shared/io.js';
10
10
  import { extractModified } from '../detectors/lifeMeas.js';
11
11
  import { buildEntityGraph } from '../observations/entityGraph.js';
12
12
  import { buildSourceIdentityChain } from '../observations/sourceIdentity.js';
13
+ import { pageArtifactRecord } from '../evidence/hashes.js';
13
14
 
14
15
  /** `citable audit [scope]` — run detectors and produce an evidence package. */
15
- export async function audit(root, { target, scope, baseUrl, refDate, viewport = null } = {}) {
16
- const ctx = await buildContext(root, { target, baseUrl, refDate, viewport });
16
+ export async function audit(root, {
17
+ target, scope, baseUrl, refDate, viewport = null,
18
+ maxPages, timeBudgetSeconds, fetcher, concurrency = 1,
19
+ } = {}) {
20
+ const ctx = await buildContext(root, {
21
+ target, baseUrl, refDate, viewport, maxPages, timeBudgetSeconds, fetcher, concurrency,
22
+ });
17
23
  const detectors = selectDetectors({ scope });
18
24
 
19
25
  const run = createRun(root, {
@@ -36,10 +42,32 @@ export async function audit(root, { target, scope, baseUrl, refDate, viewport =
36
42
  if (ctx.site?.fetchErrors?.length) {
37
43
  run.manifest.errors.push(...ctx.site.fetchErrors);
38
44
  }
39
- if (ctx.site?.crawl?.truncated) {
45
+ if (ctx.site?.crawl?.stopReason === 'page_budget_exhausted') {
40
46
  run.manifest.incomplete_checks.push(
41
47
  `URL collection reached the ${ctx.site.crawl.maxPages}-page limit with ${ctx.site.crawl.pendingUrlCount} discovered URL(s) pending; sitemap absence checks are incomplete.`
42
48
  );
49
+ } else if (ctx.site?.crawl?.stopReason === 'time_budget_exhausted') {
50
+ run.manifest.incomplete_checks.push(
51
+ `URL collection reached the ${ctx.site.crawl.timeBudgetSeconds}-second time budget; sitemap absence checks are incomplete.`
52
+ );
53
+ }
54
+
55
+ // Establish and persist the collection contract before detectors execute.
56
+ // Detectors receive this exact provisional evidence envelope through ctx so
57
+ // they cannot infer completeness from page-array length or legacy crawl data.
58
+ let coverage = null;
59
+ if (ctx.site?.previewCoverage) {
60
+ try {
61
+ coverage = ctx.site.previewCoverage();
62
+ run.manifest.coverage_status = coverage.coverage_status;
63
+ run.writeArtifact('coverage.json', coverage);
64
+ ctx.coverage = coverage;
65
+ } catch (error) {
66
+ run.manifest.execution_status = 'failed';
67
+ run.manifest.errors.push(`coverage: ${error.message}`);
68
+ run.finalize('failed');
69
+ throw error;
70
+ }
43
71
  }
44
72
 
45
73
  const { findings, detectorsRun, detectorsSkipped, errors } = runDetectors(detectors, ctx);
@@ -47,6 +75,27 @@ export async function audit(root, { target, scope, baseUrl, refDate, viewport =
47
75
  run.manifest.detectors_skipped = detectorsSkipped;
48
76
  run.manifest.errors.push(...errors);
49
77
 
78
+ // Seal the ledger after detector evaluation so the same artifact records
79
+ // evaluated resources while retaining the pre-detector persistence proof.
80
+ if (ctx.site?.finalizeCoverage) {
81
+ try {
82
+ coverage = ctx.site.finalizeCoverage({ evaluated: true });
83
+ run.manifest.coverage_status = coverage.coverage_status;
84
+ run.manifest.determination_status = coverage.coverage_status === 'complete' ? 'supported' : 'qualified';
85
+ run.writeArtifact('coverage.json', coverage);
86
+ if (coverage.coverage_status !== 'complete') {
87
+ run.manifest.incomplete_checks.push(
88
+ `Coverage is ${coverage.coverage_status}; conclusions are limited to the successfully observed corpus.`,
89
+ );
90
+ }
91
+ } catch (error) {
92
+ run.manifest.execution_status = 'failed';
93
+ run.manifest.errors.push(`coverage: ${error.message}`);
94
+ run.finalize('failed');
95
+ throw error;
96
+ }
97
+ }
98
+
50
99
  // Validate every finding against the data contract; a contract breach fails the run.
51
100
  const invalid = [];
52
101
  for (const f of findings) {
@@ -66,6 +115,7 @@ export async function audit(root, { target, scope, baseUrl, refDate, viewport =
66
115
  detectorsSkipped,
67
116
  promptResults: ctx.promptResults || [],
68
117
  targetOrigin: ctx.site?.baseUrl ? new URL(ctx.site.baseUrl).origin : null,
118
+ coverage,
69
119
  });
70
120
  run.writeArtifact('summary.json', summary);
71
121
  run.writeArtifact('inputs.json', {
@@ -73,6 +123,8 @@ export async function audit(root, { target, scope, baseUrl, refDate, viewport =
73
123
  registry_counts: Object.fromEntries(Object.entries(ctx.registries).map(([k, v]) => [k, v.entries.length])),
74
124
  pages_audited: ctx.site?.pages.length ?? 0,
75
125
  crawl_coverage: ctx.site?.crawl ?? null,
126
+ coverage_status: coverage?.coverage_status ?? 'not_applicable',
127
+ coverage_populations: coverage?.populations ?? null,
76
128
  });
77
129
  run.writeArtifact('environment.json', {
78
130
  node: process.version, platform: process.platform, cwd: root,
@@ -80,9 +132,8 @@ export async function audit(root, { target, scope, baseUrl, refDate, viewport =
80
132
  });
81
133
  if (ctx.site) {
82
134
  run.writeArtifact('pages/index.json', ctx.site.pages.map((p) => ({
83
- url: p.url, status: p.status, title: p.title, canonicals: p.canonicals,
84
- noindex: p.noindex, wordCount: p.wordCount, sourceFile: p.sourceFile,
85
- contentHash: ctx.hashPage(p),
135
+ ...pageArtifactRecord(p),
136
+ artifact_hash: p.artifact_hash ?? null,
86
137
  })));
87
138
  if (ctx.site.robotsText != null) run.writeArtifact('robots/robots.txt', ctx.site.robotsText);
88
139
  for (const sm of ctx.site.sitemaps) {
@@ -102,14 +153,26 @@ export async function audit(root, { target, scope, baseUrl, refDate, viewport =
102
153
  }
103
154
  }
104
155
 
105
- const report = renderMarkdownReport({ findings, manifest: run.manifest, summary, detectorsSkipped });
156
+ const report = renderMarkdownReport({ findings, manifest: run.manifest, summary, detectorsSkipped, coverage });
106
157
  run.writeArtifact('report.md', report);
107
158
 
108
159
  // Update the latest page snapshot for regression/freshness comparison
109
160
  if (ctx.site) {
110
161
  const snap = { taken_at: run.manifest.timestamp, run_id: run.runId, pages: {} };
111
162
  for (const p of ctx.site.pages) {
112
- snap.pages[p.url] = { contentHash: ctx.hashPage(p), dateModified: extractModified(p), status: p.status };
163
+ snap.pages[p.url] = {
164
+ contentHash: p.extracted_text_hash ?? ctx.hashPage(p),
165
+ hash_semantics: 'extracted_text_v1',
166
+ extracted_text_hash: p.extracted_text_hash ?? ctx.hashPage(p),
167
+ response_body_hash: p.response_body_hash ?? null,
168
+ structured_data_hash: p.structured_data_hash ?? null,
169
+ evidence_hash: p.evidence_hash ?? null,
170
+ artifact_hash: p.artifact_hash ?? null,
171
+ resource_id: p.urlIdentity?.resource_id ?? null,
172
+ url_identity: p.urlIdentity ?? null,
173
+ dateModified: extractModified(p),
174
+ status: p.status,
175
+ };
113
176
  }
114
177
  const snapDir = path.join(root, '.citable', 'snapshots');
115
178
  fs.mkdirSync(snapDir, { recursive: true });
@@ -121,6 +184,10 @@ export async function audit(root, { target, scope, baseUrl, refDate, viewport =
121
184
 
122
185
  const status = run.manifest.errors.length ? 'completed_with_warnings'
123
186
  : run.manifest.incomplete_checks.length ? 'incomplete' : 'completed';
187
+ // Successful persistence/execution is independent from collection coverage
188
+ // and legacy warning status. A run with fetch warnings can still complete as
189
+ // an execution while its coverage remains indeterminate or truncated.
190
+ run.manifest.execution_status = 'completed';
124
191
  const dir = run.finalize(status);
125
192
  return { runId: run.runId, dir, findings, summary, manifest: run.manifest, report };
126
193
  }
@@ -33,9 +33,14 @@ jobs:
33
33
  `;
34
34
  }
35
35
 
36
- export function formatPrReviewComment(findings = []) {
36
+ export function formatPrReviewComment(findings = [], determination = null) {
37
37
  if (findings.length === 0) {
38
- return '### Citable CRO Sentinel: All conversion funnels and components verified clean with zero detected friction.';
38
+ const status = determination?.determination_status || 'indeterminate';
39
+ if (status !== 'supported') {
40
+ const coverage = determination?.coverage_status ? ` (coverage: ${determination.coverage_status})` : '';
41
+ return `### ⚠️ Citable CRO Sentinel: No determination can be made from the available evidence${coverage}; insufficient evidence for a clean determination. No finding was observed in the evaluated subset; this is not a verified result.`;
42
+ }
43
+ return '### ✅ Citable CRO Sentinel: No findings were observed over the supported evidence scope.';
39
44
  }
40
45
 
41
46
  const lines = [
@@ -1,63 +1,171 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { readJson } from '../shared/io.js';
4
+ import { loadVerifiedRun } from '../shared/verifiedRunLoader.js';
5
+ import { normalizeUrlIdentity } from '../crawler/urlIdentity.js';
4
6
 
5
- /**
6
- * `citable compare-snapshots [runA runB]` — regression comparison between two audit runs.
7
- * Defaults to the two most recent runs containing findings.json.
8
- */
9
- export function compareSnapshots(root, { runA, runB } = {}) {
10
- const runsDir = path.join(root, '.citable', 'runs');
11
- if (!fs.existsSync(runsDir)) throw new Error('no runs recorded yet');
12
- const runs = fs.readdirSync(runsDir)
13
- .filter((r) => fs.existsSync(path.join(runsDir, r, 'findings.json')))
14
- .sort();
15
- if (!runA || !runB) {
16
- if (runs.length < 2) throw new Error(`need two runs with findings to compare; found ${runs.length}`);
17
- runB = runB ?? runs[runs.length - 1];
18
- runA = runA ?? runs[runs.length - 2];
7
+ export const COMPARISON_STATES = Object.freeze([
8
+ 'persisted', 'resolved', 'new', 'changed', 'not_comparable', 'not_reobserved', 'indeterminate',
9
+ ]);
10
+
11
+ function normalizeCandidate(value) {
12
+ if (!value) return null;
13
+ try { return normalizeUrlIdentity(String(value)); } catch { return String(value); }
14
+ }
15
+
16
+ /** Keep effective/requested identity distinct; canonical is never fetch identity. */
17
+ export function resourceIdentityFor(findingOrPage) {
18
+ const subject = findingOrPage?.subject || findingOrPage || {};
19
+ const identity = subject.urlIdentity || subject.url_identity || {};
20
+ const ids = new Set([
21
+ subject.resource_id, identity.resource_id,
22
+ subject.classification?.resource_id, subject.evaluation?.resource_id,
23
+ ...(findingOrPage?.evidence_scope?.resource_ids || []),
24
+ ].filter(Boolean).map(String));
25
+ const requested = normalizeCandidate(identity.requested?.normalized_url || identity.requested?.url || subject.requested_url);
26
+ const effective = normalizeCandidate(identity.effective?.normalized_url || identity.effective?.url
27
+ || subject.effective_url || subject.normalized_url || subject.url || subject.identifier);
28
+ const canonical = normalizeCandidate(identity.canonical?.normalized_url || identity.canonical?.url || subject.canonical_url);
29
+ const fallback = new Set([requested, effective, normalizeCandidate(subject.url || subject.identifier)].filter(Boolean));
30
+ return { resourceIds: ids, requested, effective, canonical, fallbackUrls: fallback };
31
+ }
32
+
33
+ export function sameResourceIdentity(left, right) {
34
+ const a = resourceIdentityFor(left); const b = resourceIdentityFor(right);
35
+ if (a.resourceIds.size && b.resourceIds.size) {
36
+ return [...a.resourceIds].some((id) => b.resourceIds.has(id));
19
37
  }
20
- const load = (r) => {
21
- const dir = path.join(runsDir, r);
22
- if (!fs.existsSync(path.join(dir, 'findings.json'))) throw new Error(`run ${r} has no findings.json`);
23
- return {
24
- findings: readJson(path.join(dir, 'findings.json')),
25
- manifest: readJson(path.join(dir, 'manifest.json')),
26
- };
27
- };
28
- const a = load(runA);
29
- const b = load(runB);
30
- const changedKeys = (left = {}, right = {}) => [...new Set([...Object.keys(left), ...Object.keys(right)])].filter((key) => left[key] !== right[key]);
38
+ if (a.effective && b.effective) return a.effective === b.effective;
39
+ if (a.requested && b.requested) return a.requested === b.requested;
40
+ // URL fallback is allowed only when each side has exactly one candidate.
41
+ return a.fallbackUrls.size === 1 && b.fallbackUrls.size === 1
42
+ && [...a.fallbackUrls][0] === [...b.fallbackUrls][0];
43
+ }
44
+
45
+ function findingKey(finding) {
46
+ const identity = resourceIdentityFor(finding);
47
+ const id = identity.resourceIds.size ? [...identity.resourceIds].sort().join(',')
48
+ : identity.effective || identity.requested || (identity.fallbackUrls.size === 1 ? [...identity.fallbackUrls][0] : JSON.stringify(finding?.subject || {}));
49
+ return `${finding?.detector_id || ''}|${id}`;
50
+ }
51
+
52
+ function loadCoverage(dir) {
53
+ const file = path.join(dir, 'coverage.json');
54
+ return fs.existsSync(file) ? readJson(file) : null;
55
+ }
56
+
57
+ function coverageResource(coverage, finding) {
58
+ if (!Array.isArray(coverage?.resources)) return null;
59
+ const target = resourceIdentityFor(finding);
60
+ const byId = coverage.resources.filter((resource) => {
61
+ const ids = resourceIdentityFor(resource).resourceIds;
62
+ return [...target.resourceIds].some((id) => ids.has(id));
63
+ });
64
+ if (target.resourceIds.size) return byId.length === 1 ? byId[0] : null;
65
+ const byUrl = coverage.resources.filter((resource) => sameResourceIdentity(finding, resource));
66
+ return byUrl.length === 1 ? byUrl[0] : null;
67
+ }
68
+
69
+ export function assessReobservation(finding, coverage) {
70
+ if (!coverage) return { state: 'not_reobserved', reason: 'comparison_run_has_no_coverage_artifact' };
71
+ const resource = coverageResource(coverage, finding);
72
+ if (!resource) return { state: 'not_reobserved', reason: 'resource_not_reobserved_in_comparison_run' };
73
+ if (['failed', 'indeterminate', 'unvisited', 'valid_but_unevaluated'].includes(resource.state)) {
74
+ return { state: resource.state === 'indeterminate' ? 'indeterminate' : 'not_reobserved', reason: `resource_state_${resource.state}`, resource };
75
+ }
76
+ if (resource.state !== 'evaluated') return { state: 'not_reobserved', reason: `resource_state_${resource.state || 'unknown'}`, resource };
77
+ if (finding?.evidence_scope?.requirement === 'exhaustive_requested_scope' && coverage.coverage_status !== 'complete') {
78
+ return { state: 'indeterminate', reason: 'exhaustive_scope_coverage_incomplete', resource };
79
+ }
80
+ return { state: 'resolved', reason: 'resource_reobserved_with_sufficient_coverage', resource };
81
+ }
82
+
83
+ function changedKeys(left = {}, right = {}) {
84
+ return [...new Set([...Object.keys(left), ...Object.keys(right)])].filter((key) => left[key] !== right[key]);
85
+ }
86
+
87
+ function comparabilityFor(a, b) {
31
88
  const changeDimensions = {
32
89
  resource_changed: changedKeys(a.manifest.input_hashes, b.manifest.input_hashes),
33
90
  evidence_artifacts_changed: changedKeys(a.manifest.output_hashes, b.manifest.output_hashes),
34
91
  detector_set_changed: JSON.stringify(a.manifest.detectors_run) !== JSON.stringify(b.manifest.detectors_run),
35
92
  configuration_changed: a.manifest.configuration_hash !== b.manifest.configuration_hash,
36
- observation_method_changed: a.manifest.command !== b.manifest.command || JSON.stringify(a.manifest.argv) !== JSON.stringify(b.manifest.argv) || a.manifest.target?.kind !== b.manifest.target?.kind,
93
+ observation_method_changed: a.manifest.command !== b.manifest.command
94
+ || JSON.stringify(a.manifest.argv) !== JSON.stringify(b.manifest.argv)
95
+ || a.manifest.target?.kind !== b.manifest.target?.kind,
37
96
  tool_changed: a.manifest.tool_version !== b.manifest.tool_version,
38
97
  external_system_may_have_changed: a.manifest.target?.kind === 'url' || b.manifest.target?.kind === 'url',
39
98
  };
40
- const key = (f) => `${f.detector_id}|${f.subject.identifier}|${f.observation.summary}`;
41
- const aKeys = new Map(a.findings.map((f) => [key(f), f]));
42
- const bKeys = new Map(b.findings.map((f) => [key(f), f]));
43
- const regressions = [...bKeys.entries()].filter(([k]) => !aKeys.has(k)).map(([, f]) => f);
44
- const resolved = [...aKeys.entries()].filter(([k]) => !bKeys.has(k)).map(([, f]) => f);
45
- const persisting = [...bKeys.entries()].filter(([k]) => aKeys.has(k)).map(([, f]) => f);
46
99
  return {
47
- runA, runB,
48
- baseline_timestamp: a.manifest.timestamp,
49
- comparison_timestamp: b.manifest.timestamp,
50
- regressions, resolved, persisting,
51
- comparability: {
52
- comparable: !changeDimensions.detector_set_changed && !changeDimensions.configuration_changed && !changeDimensions.observation_method_changed && !changeDimensions.tool_changed,
53
- change_dimensions: changeDimensions,
54
- limitation: 'These dimensions identify observed differences between run envelopes; they do not establish what caused a finding change.',
55
- },
56
- summary: {
57
- new_findings: regressions.length,
58
- resolved_findings: resolved.length,
59
- persisting_findings: persisting.length,
60
- regression_critical_or_high: regressions.filter((f) => ['critical', 'high'].includes(f.classification.severity)).length,
61
- },
100
+ comparable: !changeDimensions.detector_set_changed
101
+ && !changeDimensions.configuration_changed
102
+ && !changeDimensions.observation_method_changed
103
+ && !changeDimensions.tool_changed,
104
+ change_dimensions: changeDimensions,
105
+ limitation: 'These dimensions identify observed differences between run envelopes; they do not establish what caused a finding change.',
106
+ };
107
+ }
108
+
109
+ function withState(finding, state, reason, extra = {}) {
110
+ return { ...finding, comparison_state: state, comparison_provenance: { reason, ...extra } };
111
+ }
112
+
113
+ /** Compare findings only after checking that their responsible resource was reobserved. */
114
+ export function compareSnapshots(root, { runA, runB } = {}) {
115
+ const runsDir = path.join(root, '.citable', 'runs');
116
+ if (!fs.existsSync(runsDir)) throw new Error('no runs recorded yet');
117
+ const runs = fs.readdirSync(runsDir).filter((r) => fs.existsSync(path.join(runsDir, r, 'findings.json'))).sort();
118
+ if (!runA || !runB) {
119
+ if (runs.length < 2) throw new Error(`need two runs with findings to compare; found ${runs.length}`);
120
+ runB = runB ?? runs[runs.length - 1];
121
+ runA = runA ?? runs[runs.length - 2];
122
+ }
123
+ const load = (runId) => {
124
+ const dir = path.join(runsDir, runId);
125
+ if (!fs.existsSync(path.join(dir, 'findings.json'))) throw new Error(`run ${runId} has no findings.json`);
126
+ const loaded = loadVerifiedRun(dir, { requireCompletedExecution: false, allowLegacy: true, requireCoverage: false });
127
+ return { findings: loaded.findings, manifest: loaded.manifest, coverage: loaded.coverage };
128
+ };
129
+ const a = load(runA); const b = load(runB);
130
+ const comparability = comparabilityFor(a, b);
131
+ const aFindings = a.findings;
132
+ const bFindings = b.findings;
133
+ const matchedA = new Set();
134
+ const result = {
135
+ runA, runB, baseline_timestamp: a.manifest.timestamp, comparison_timestamp: b.manifest.timestamp,
136
+ regressions: [], resolved: [], persisting: [], new: [], changed: [], not_comparable: [], not_reobserved: [], indeterminate: [],
137
+ comparability,
138
+ };
139
+ for (const finding of bFindings) {
140
+ const priorIndex = aFindings.findIndex((candidate, index) => !matchedA.has(index)
141
+ && candidate.detector_id === finding.detector_id && sameResourceIdentity(candidate, finding));
142
+ const prior = priorIndex >= 0 ? aFindings[priorIndex] : null;
143
+ if (priorIndex >= 0) matchedA.add(priorIndex);
144
+ if (!prior) {
145
+ const item = withState(finding, 'new', 'finding_not_present_in_baseline');
146
+ result.new.push(item); result.regressions.push(item); continue;
147
+ }
148
+ if (!comparability.comparable) result.not_comparable.push(withState(finding, 'not_comparable', 'run_envelopes_not_comparable'));
149
+ else if (JSON.stringify(prior.observation) !== JSON.stringify(finding.observation)) result.changed.push(withState(finding, 'changed', 'finding_observation_changed'));
150
+ else result.persisting.push(withState(finding, 'persisted', 'finding_observation_persisted'));
151
+ }
152
+ for (let index = 0; index < aFindings.length; index += 1) {
153
+ const finding = aFindings[index];
154
+ if (matchedA.has(index)) continue;
155
+ if (!comparability.comparable) { result.not_comparable.push(withState(finding, 'not_comparable', 'run_envelopes_not_comparable')); continue; }
156
+ const state = assessReobservation(finding, b.coverage);
157
+ const item = withState(finding, state.state, state.reason, {
158
+ resource: state.resource?.normalized_url || null, comparison_coverage_status: b.coverage?.coverage_status || 'not_available',
159
+ });
160
+ if (state.state === 'resolved') result.resolved.push(item);
161
+ else if (state.state === 'indeterminate') result.indeterminate.push(item);
162
+ else result.not_reobserved.push(item);
163
+ }
164
+ result.summary = {
165
+ new_findings: result.new.length, changed_findings: result.changed.length, resolved_findings: result.resolved.length,
166
+ persisting_findings: result.persisting.length, not_comparable_findings: result.not_comparable.length,
167
+ not_reobserved_findings: result.not_reobserved.length, indeterminate_findings: result.indeterminate.length,
168
+ regression_critical_or_high: result.regressions.filter((f) => ['critical', 'high'].includes(f.classification?.severity)).length,
62
169
  };
170
+ return result;
63
171
  }
@@ -79,16 +79,37 @@ export async function syncConnection(root, { connectionId, startDate, endDate, a
79
79
  }
80
80
  throw error;
81
81
  }
82
+ // Connector collection may preserve a partial result even when the provider
83
+ // reports an authorization/quota state. Keep the result available to direct
84
+ // connector callers, while retaining the command's legacy state transition.
85
+ if (result.connectorError?.connectorState) {
86
+ const error = result.connectorError;
87
+ const failed = { ...loaded.registries.connections, entries: loaded.registries.connections.entries.map((item) => item.connection_id === connectionId ? { ...item, state: error.connectorState, limitations: [...(result.collection?.errors || []), 'Connector synchronization did not complete; inspect collection errors.'] } : item) };
88
+ saveRegistry(root, 'connections', failed);
89
+ throw error;
90
+ }
91
+ const collection = result.collection || {
92
+ schema_version: 1,
93
+ items: result.rows,
94
+ pagination_state: { pages_requested: 1, pages_retrieved: 1 },
95
+ provider_reported_total: null,
96
+ retrieved_total: result.rows.length,
97
+ coverage_status: 'indeterminate',
98
+ continuation_state: null,
99
+ limitations: ['Connector did not provide a collection contract.'],
100
+ errors: [],
101
+ };
102
+ const collectionLimitations = [...collection.limitations, ...collection.errors];
82
103
  const observations = result.rows.map((row) => envelope('metric', {
83
104
  metric_id: row.metric.metric_id, provider: connector.provider, external_name: row.metric.external_name,
84
105
  value: Number(row.value), unit: row.metric.unit === 'custom' ? row.metric.custom_unit : row.metric.unit,
85
106
  aggregation: row.metric.aggregation, observed_at: row.observed_at, period_start: row.observed_at,
86
107
  period_end: row.observed_at, dimensions: row.dimensions,
87
- }, { method: 'live_api', source: connector.provider, limitations: [...row.metric.limitations, ...result.limitations] }));
108
+ }, { method: 'live_api', source: connector.provider, limitations: [...row.metric.limitations, ...collectionLimitations] }));
88
109
  const run = observationRun(root, 'connect sync', loaded.connection.property_id, observations);
89
- const updated = { ...loaded.registries.connections, entries: loaded.registries.connections.entries.map((item) => item.connection_id === connectionId ? { ...item, state: 'synchronized', last_synchronized_at: new Date().toISOString(), cursor: result.cursor, limitations: result.limitations } : item) };
110
+ const updated = { ...loaded.registries.connections, entries: loaded.registries.connections.entries.map((item) => item.connection_id === connectionId ? { ...item, state: ['truncated', 'indeterminate'].includes(collection.coverage_status) ? 'partial' : 'synchronized', last_synchronized_at: new Date().toISOString(), cursor: result.cursor, limitations: collectionLimitations } : item) };
90
111
  saveRegistry(root, 'connections', updated);
91
- return { ...run, connection_id: connectionId, provider: connector.provider };
112
+ return { ...run, connection_id: connectionId, provider: connector.provider, collection };
92
113
  }
93
114
 
94
115
  export async function readCmsContent(root, { connectionId, targetId, accessToken, env, fetchImpl } = {}) {
@@ -119,4 +140,3 @@ export async function applyCmsRemediation(root, { connectionId, input, write = f
119
140
 
120
141
  export { submitIndexNow, verifyHostKey, buildIndexNowPayload, normalizeHost, parseUrlsInput } from '../connectors/indexnow.js';
121
142
  export { collectMcpEvidence } from '../connectors/mcp/pilot.js';
122
-