@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.
- package/CHANGELOG.md +18 -0
- package/README.md +12 -1
- package/dist/universal/.agents/skills/citable/SKILL.md +11 -2
- package/dist/universal/.agents/skills/citable/VERSION +1 -1
- package/dist/universal/.agents/skills/citable/manifest.json +15 -12
- package/dist/universal/.agents/skills/citable/schemas/action-plan.schema.json +38 -1
- package/dist/universal/.agents/skills/citable/schemas/audit-coverage.schema.json +118 -0
- package/dist/universal/.agents/skills/citable/schemas/collection-result.schema.json +35 -0
- package/dist/universal/.agents/skills/citable/schemas/config.schema.json +2 -0
- package/dist/universal/.agents/skills/citable/schemas/cro-report.schema.json +8 -1
- package/dist/universal/.agents/skills/citable/schemas/finding.schema.json +105 -2
- package/dist/universal/.agents/skills/citable/schemas/remediation-verification.schema.json +12 -1
- package/dist/universal/.agents/skills/citable/schemas/run.schema.json +15 -2
- package/dist/universal/.agents/skills/citable/schemas/search-report.schema.json +8 -1
- package/dist/universal/.agents/skills/citable/schemas/summary.schema.json +41 -0
- package/dist/universal/.claude/agents/citable/manifest.json +2 -2
- package/dist/universal/.claude/skills/citable/SKILL.md +11 -2
- package/dist/universal/.claude/skills/citable/VERSION +1 -1
- package/dist/universal/.claude/skills/citable/manifest.json +15 -12
- package/dist/universal/.claude/skills/citable/schemas/action-plan.schema.json +38 -1
- package/dist/universal/.claude/skills/citable/schemas/audit-coverage.schema.json +118 -0
- package/dist/universal/.claude/skills/citable/schemas/collection-result.schema.json +35 -0
- package/dist/universal/.claude/skills/citable/schemas/config.schema.json +2 -0
- package/dist/universal/.claude/skills/citable/schemas/cro-report.schema.json +8 -1
- package/dist/universal/.claude/skills/citable/schemas/finding.schema.json +105 -2
- package/dist/universal/.claude/skills/citable/schemas/remediation-verification.schema.json +12 -1
- package/dist/universal/.claude/skills/citable/schemas/run.schema.json +15 -2
- package/dist/universal/.claude/skills/citable/schemas/search-report.schema.json +8 -1
- package/dist/universal/.claude/skills/citable/schemas/summary.schema.json +41 -0
- package/dist/universal/.cursor/skills/citable/SKILL.md +11 -2
- package/dist/universal/.cursor/skills/citable/VERSION +1 -1
- package/dist/universal/.cursor/skills/citable/manifest.json +15 -12
- package/dist/universal/.cursor/skills/citable/schemas/action-plan.schema.json +38 -1
- package/dist/universal/.cursor/skills/citable/schemas/audit-coverage.schema.json +118 -0
- package/dist/universal/.cursor/skills/citable/schemas/collection-result.schema.json +35 -0
- package/dist/universal/.cursor/skills/citable/schemas/config.schema.json +2 -0
- package/dist/universal/.cursor/skills/citable/schemas/cro-report.schema.json +8 -1
- package/dist/universal/.cursor/skills/citable/schemas/finding.schema.json +105 -2
- package/dist/universal/.cursor/skills/citable/schemas/remediation-verification.schema.json +12 -1
- package/dist/universal/.cursor/skills/citable/schemas/run.schema.json +15 -2
- package/dist/universal/.cursor/skills/citable/schemas/search-report.schema.json +8 -1
- package/dist/universal/.cursor/skills/citable/schemas/summary.schema.json +41 -0
- package/dist/universal/.gemini/skills/citable/SKILL.md +11 -2
- package/dist/universal/.gemini/skills/citable/VERSION +1 -1
- package/dist/universal/.gemini/skills/citable/manifest.json +15 -12
- package/dist/universal/.gemini/skills/citable/schemas/action-plan.schema.json +38 -1
- package/dist/universal/.gemini/skills/citable/schemas/audit-coverage.schema.json +118 -0
- package/dist/universal/.gemini/skills/citable/schemas/collection-result.schema.json +35 -0
- package/dist/universal/.gemini/skills/citable/schemas/config.schema.json +2 -0
- package/dist/universal/.gemini/skills/citable/schemas/cro-report.schema.json +8 -1
- package/dist/universal/.gemini/skills/citable/schemas/finding.schema.json +105 -2
- package/dist/universal/.gemini/skills/citable/schemas/remediation-verification.schema.json +12 -1
- package/dist/universal/.gemini/skills/citable/schemas/run.schema.json +15 -2
- package/dist/universal/.gemini/skills/citable/schemas/search-report.schema.json +8 -1
- package/dist/universal/.gemini/skills/citable/schemas/summary.schema.json +41 -0
- package/dist/universal/.github/skills/citable/SKILL.md +11 -2
- package/dist/universal/.github/skills/citable/VERSION +1 -1
- package/dist/universal/.github/skills/citable/manifest.json +15 -12
- package/dist/universal/.github/skills/citable/schemas/action-plan.schema.json +38 -1
- package/dist/universal/.github/skills/citable/schemas/audit-coverage.schema.json +118 -0
- package/dist/universal/.github/skills/citable/schemas/collection-result.schema.json +35 -0
- package/dist/universal/.github/skills/citable/schemas/config.schema.json +2 -0
- package/dist/universal/.github/skills/citable/schemas/cro-report.schema.json +8 -1
- package/dist/universal/.github/skills/citable/schemas/finding.schema.json +105 -2
- package/dist/universal/.github/skills/citable/schemas/remediation-verification.schema.json +12 -1
- package/dist/universal/.github/skills/citable/schemas/run.schema.json +15 -2
- package/dist/universal/.github/skills/citable/schemas/search-report.schema.json +8 -1
- package/dist/universal/.github/skills/citable/schemas/summary.schema.json +41 -0
- package/dist/universal/.kiro/skills/citable/SKILL.md +11 -2
- package/dist/universal/.kiro/skills/citable/VERSION +1 -1
- package/dist/universal/.kiro/skills/citable/manifest.json +15 -12
- package/dist/universal/.kiro/skills/citable/schemas/action-plan.schema.json +38 -1
- package/dist/universal/.kiro/skills/citable/schemas/audit-coverage.schema.json +118 -0
- package/dist/universal/.kiro/skills/citable/schemas/collection-result.schema.json +35 -0
- package/dist/universal/.kiro/skills/citable/schemas/config.schema.json +2 -0
- package/dist/universal/.kiro/skills/citable/schemas/cro-report.schema.json +8 -1
- package/dist/universal/.kiro/skills/citable/schemas/finding.schema.json +105 -2
- package/dist/universal/.kiro/skills/citable/schemas/remediation-verification.schema.json +12 -1
- package/dist/universal/.kiro/skills/citable/schemas/run.schema.json +15 -2
- package/dist/universal/.kiro/skills/citable/schemas/search-report.schema.json +8 -1
- package/dist/universal/.kiro/skills/citable/schemas/summary.schema.json +41 -0
- package/dist/universal/.opencode/skills/citable/SKILL.md +11 -2
- package/dist/universal/.opencode/skills/citable/VERSION +1 -1
- package/dist/universal/.opencode/skills/citable/manifest.json +15 -12
- package/dist/universal/.opencode/skills/citable/schemas/action-plan.schema.json +38 -1
- package/dist/universal/.opencode/skills/citable/schemas/audit-coverage.schema.json +118 -0
- package/dist/universal/.opencode/skills/citable/schemas/collection-result.schema.json +35 -0
- package/dist/universal/.opencode/skills/citable/schemas/config.schema.json +2 -0
- package/dist/universal/.opencode/skills/citable/schemas/cro-report.schema.json +8 -1
- package/dist/universal/.opencode/skills/citable/schemas/finding.schema.json +105 -2
- package/dist/universal/.opencode/skills/citable/schemas/remediation-verification.schema.json +12 -1
- package/dist/universal/.opencode/skills/citable/schemas/run.schema.json +15 -2
- package/dist/universal/.opencode/skills/citable/schemas/search-report.schema.json +8 -1
- package/dist/universal/.opencode/skills/citable/schemas/summary.schema.json +41 -0
- package/dist/universal/.pi/agent/skills/citable/SKILL.md +11 -2
- package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
- package/dist/universal/.pi/agent/skills/citable/manifest.json +15 -12
- package/dist/universal/.pi/agent/skills/citable/schemas/action-plan.schema.json +38 -1
- package/dist/universal/.pi/agent/skills/citable/schemas/audit-coverage.schema.json +118 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/collection-result.schema.json +35 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/config.schema.json +2 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/cro-report.schema.json +8 -1
- package/dist/universal/.pi/agent/skills/citable/schemas/finding.schema.json +105 -2
- package/dist/universal/.pi/agent/skills/citable/schemas/remediation-verification.schema.json +12 -1
- package/dist/universal/.pi/agent/skills/citable/schemas/run.schema.json +15 -2
- package/dist/universal/.pi/agent/skills/citable/schemas/search-report.schema.json +8 -1
- package/dist/universal/.pi/agent/skills/citable/schemas/summary.schema.json +41 -0
- package/dist/universal/.qoder/skills/citable/SKILL.md +11 -2
- package/dist/universal/.qoder/skills/citable/VERSION +1 -1
- package/dist/universal/.qoder/skills/citable/manifest.json +15 -12
- package/dist/universal/.qoder/skills/citable/schemas/action-plan.schema.json +38 -1
- package/dist/universal/.qoder/skills/citable/schemas/audit-coverage.schema.json +118 -0
- package/dist/universal/.qoder/skills/citable/schemas/collection-result.schema.json +35 -0
- package/dist/universal/.qoder/skills/citable/schemas/config.schema.json +2 -0
- package/dist/universal/.qoder/skills/citable/schemas/cro-report.schema.json +8 -1
- package/dist/universal/.qoder/skills/citable/schemas/finding.schema.json +105 -2
- package/dist/universal/.qoder/skills/citable/schemas/remediation-verification.schema.json +12 -1
- package/dist/universal/.qoder/skills/citable/schemas/run.schema.json +15 -2
- package/dist/universal/.qoder/skills/citable/schemas/search-report.schema.json +8 -1
- package/dist/universal/.qoder/skills/citable/schemas/summary.schema.json +41 -0
- package/dist/universal/.rovodev/skills/citable/SKILL.md +11 -2
- package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
- package/dist/universal/.rovodev/skills/citable/manifest.json +15 -12
- package/dist/universal/.rovodev/skills/citable/schemas/action-plan.schema.json +38 -1
- package/dist/universal/.rovodev/skills/citable/schemas/audit-coverage.schema.json +118 -0
- package/dist/universal/.rovodev/skills/citable/schemas/collection-result.schema.json +35 -0
- package/dist/universal/.rovodev/skills/citable/schemas/config.schema.json +2 -0
- package/dist/universal/.rovodev/skills/citable/schemas/cro-report.schema.json +8 -1
- package/dist/universal/.rovodev/skills/citable/schemas/finding.schema.json +105 -2
- package/dist/universal/.rovodev/skills/citable/schemas/remediation-verification.schema.json +12 -1
- package/dist/universal/.rovodev/skills/citable/schemas/run.schema.json +15 -2
- package/dist/universal/.rovodev/skills/citable/schemas/search-report.schema.json +8 -1
- package/dist/universal/.rovodev/skills/citable/schemas/summary.schema.json +41 -0
- package/dist/universal/.trae/skills/citable/SKILL.md +11 -2
- package/dist/universal/.trae/skills/citable/VERSION +1 -1
- package/dist/universal/.trae/skills/citable/manifest.json +15 -12
- package/dist/universal/.trae/skills/citable/schemas/action-plan.schema.json +38 -1
- package/dist/universal/.trae/skills/citable/schemas/audit-coverage.schema.json +118 -0
- package/dist/universal/.trae/skills/citable/schemas/collection-result.schema.json +35 -0
- package/dist/universal/.trae/skills/citable/schemas/config.schema.json +2 -0
- package/dist/universal/.trae/skills/citable/schemas/cro-report.schema.json +8 -1
- package/dist/universal/.trae/skills/citable/schemas/finding.schema.json +105 -2
- package/dist/universal/.trae/skills/citable/schemas/remediation-verification.schema.json +12 -1
- package/dist/universal/.trae/skills/citable/schemas/run.schema.json +15 -2
- package/dist/universal/.trae/skills/citable/schemas/search-report.schema.json +8 -1
- package/dist/universal/.trae/skills/citable/schemas/summary.schema.json +41 -0
- package/dist/universal/.trae-cn/skills/citable/SKILL.md +11 -2
- package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
- package/dist/universal/.trae-cn/skills/citable/manifest.json +15 -12
- package/dist/universal/.trae-cn/skills/citable/schemas/action-plan.schema.json +38 -1
- package/dist/universal/.trae-cn/skills/citable/schemas/audit-coverage.schema.json +118 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/collection-result.schema.json +35 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/config.schema.json +2 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/cro-report.schema.json +8 -1
- package/dist/universal/.trae-cn/skills/citable/schemas/finding.schema.json +105 -2
- package/dist/universal/.trae-cn/skills/citable/schemas/remediation-verification.schema.json +12 -1
- package/dist/universal/.trae-cn/skills/citable/schemas/run.schema.json +15 -2
- package/dist/universal/.trae-cn/skills/citable/schemas/search-report.schema.json +8 -1
- package/dist/universal/.trae-cn/skills/citable/schemas/summary.schema.json +41 -0
- package/dist/universal/manifest.json +184 -148
- package/package.json +1 -1
- package/schemas/action-plan.schema.json +38 -1
- package/schemas/audit-coverage.schema.json +118 -0
- package/schemas/collection-result.schema.json +35 -0
- package/schemas/config.schema.json +2 -0
- package/schemas/cro-report.schema.json +8 -1
- package/schemas/finding.schema.json +105 -2
- package/schemas/remediation-verification.schema.json +12 -1
- package/schemas/run.schema.json +15 -2
- package/schemas/search-report.schema.json +8 -1
- package/schemas/summary.schema.json +41 -0
- package/skill/SKILL.md +11 -2
- package/src/acceptance/reproducibility.js +5 -25
- package/src/analysis/croRoadmap.js +3 -3
- package/src/analysis/strategicRoadmap.js +3 -3
- package/src/artifacts/interchange.js +2 -6
- package/src/cli/index.js +15 -3
- package/src/commands/actionPlan.js +46 -5
- package/src/commands/applyRemediation.js +5 -5
- package/src/commands/audit.js +75 -8
- package/src/commands/ciWorkflow.js +7 -2
- package/src/commands/compareSnapshots.js +155 -47
- package/src/commands/connect.js +24 -4
- package/src/commands/context.js +41 -2
- package/src/commands/croSuite.js +51 -13
- package/src/commands/delivery.js +7 -7
- package/src/commands/governance.js +9 -10
- package/src/commands/implementationKit.js +2 -1
- package/src/commands/init.js +8 -1
- package/src/commands/previewCro.js +1 -1
- package/src/commands/prioritize.js +3 -2
- package/src/commands/reviews.js +31 -5
- package/src/commands/roadmapCmd.js +7 -13
- package/src/commands/sweep.js +15 -2
- package/src/commands/verifyRemediation.js +94 -19
- package/src/connectors/collectionResult.js +80 -0
- package/src/connectors/ga4.js +46 -11
- package/src/connectors/gsc.js +41 -6
- package/src/connectors/index.js +1 -1
- package/src/connectors/webflow.js +140 -10
- package/src/connectors/wordpress.js +92 -8
- package/src/crawler/fetch.js +220 -24
- package/src/crawler/resourceValidity.js +119 -0
- package/src/crawler/sitemap.js +435 -16
- package/src/crawler/sitemapCollector.js +364 -0
- package/src/crawler/urlIdentity.js +97 -0
- package/src/detectors/agent.js +11 -0
- package/src/detectors/ans.js +16 -1
- package/src/detectors/arch.js +6 -0
- package/src/detectors/claim.js +9 -0
- package/src/detectors/crawl.js +7 -0
- package/src/detectors/cro.js +21 -0
- package/src/detectors/cwv.js +4 -0
- package/src/detectors/entity.js +7 -0
- package/src/detectors/evd.js +9 -0
- package/src/detectors/framework.js +28 -2
- package/src/detectors/geoReco.js +16 -0
- package/src/detectors/hreflang.js +3 -0
- package/src/detectors/lifeMeas.js +9 -0
- package/src/detectors/link.js +6 -1
- package/src/detectors/page.js +11 -0
- package/src/detectors/schemaData.js +16 -1
- package/src/detectors/tech.js +34 -3
- package/src/evidence/coverage.js +448 -0
- package/src/evidence/determination.js +157 -0
- package/src/evidence/downstream.js +110 -0
- package/src/evidence/hashes.js +109 -0
- package/src/evidence/run.js +68 -8
- package/src/extractor/page.js +29 -2
- package/src/extractor/site.js +298 -50
- package/src/reporting/executiveCroReport.js +36 -13
- package/src/reporting/executiveExport.js +45 -16
- package/src/reporting/executiveSearchReport.js +56 -22
- package/src/reporting/report.js +56 -9
- package/src/shared/epistemicLanguage.js +86 -0
- package/src/shared/evidenceSourceResolver.js +48 -17
- package/src/shared/htmlEscape.js +8 -4
- package/src/shared/runPackageVerifier.js +99 -8
- 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
|
-
|
|
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, {
|
|
415
|
-
|
|
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
|
|
82
|
-
const findings =
|
|
83
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
23
|
-
if (!fs.existsSync(
|
|
24
|
-
const
|
|
25
|
-
|
|
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) {
|
package/src/commands/audit.js
CHANGED
|
@@ -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, {
|
|
16
|
-
|
|
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?.
|
|
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
|
-
|
|
84
|
-
|
|
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] = {
|
|
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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
}
|
package/src/commands/connect.js
CHANGED
|
@@ -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, ...
|
|
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:
|
|
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
|
-
|