@mcptoolshop/accessibility-suite 0.1.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/.github/workflows/ci.yml +63 -0
- package/LICENSE +21 -0
- package/README.md +37 -0
- package/docs/prov-spec/.github/workflows/ci.yml +68 -0
- package/docs/prov-spec/CHANGELOG.md +69 -0
- package/docs/prov-spec/CODE_OF_CONDUCT.md +129 -0
- package/docs/prov-spec/CONFORMANCE_LEVELS.md +223 -0
- package/docs/prov-spec/CONTRIBUTING.md +145 -0
- package/docs/prov-spec/IMPLEMENTER_CHECKLIST.md +137 -0
- package/docs/prov-spec/LICENSE +21 -0
- package/docs/prov-spec/PRESS_RELEASE.md +74 -0
- package/docs/prov-spec/README.md +182 -0
- package/docs/prov-spec/SETUP.md +135 -0
- package/docs/prov-spec/WHY.md +86 -0
- package/docs/prov-spec/examples/artifact.example.json +14 -0
- package/docs/prov-spec/examples/artifact.ref.example.json +9 -0
- package/docs/prov-spec/examples/evidence.example.json +6 -0
- package/docs/prov-spec/examples/mcp.envelope.example.json +97 -0
- package/docs/prov-spec/examples/mcp.request.example.json +28 -0
- package/docs/prov-spec/examples/prov.record.example.json +35 -0
- package/docs/prov-spec/interop/PROOF_NODE_ENGINE.md +114 -0
- package/docs/prov-spec/spec/MCP_COMPATIBILITY.md +241 -0
- package/docs/prov-spec/spec/PROV_METHODS_CATALOG.md +142 -0
- package/docs/prov-spec/spec/PROV_METHODS_SPEC.md +397 -0
- package/docs/prov-spec/spec/methods.json +213 -0
- package/docs/prov-spec/spec/schemas/artifact.ref.schema.v0.1.json +58 -0
- package/docs/prov-spec/spec/schemas/artifact.schema.v0.1.json +61 -0
- package/docs/prov-spec/spec/schemas/assist.request.schema.v0.1.json +52 -0
- package/docs/prov-spec/spec/schemas/assist.response.schema.v0.1.json +70 -0
- package/docs/prov-spec/spec/schemas/cli.error.schema.v0.1.json +78 -0
- package/docs/prov-spec/spec/schemas/evidence.schema.v0.1.json +37 -0
- package/docs/prov-spec/spec/schemas/mcp.envelope.schema.v0.1.json +141 -0
- package/docs/prov-spec/spec/schemas/mcp.request.schema.v0.1.json +79 -0
- package/docs/prov-spec/spec/schemas/methods.schema.json +93 -0
- package/docs/prov-spec/spec/schemas/prov-capabilities.schema.json +122 -0
- package/docs/prov-spec/spec/schemas/prov.record.schema.v0.1.json +133 -0
- package/docs/prov-spec/spec/vectors/adapter.wrap.envelope_v0_1/expected.json +4 -0
- package/docs/prov-spec/spec/vectors/adapter.wrap.envelope_v0_1/input.json +1 -0
- package/docs/prov-spec/spec/vectors/adapter.wrap.envelope_v0_1/negative/double_wrapped.json +14 -0
- package/docs/prov-spec/spec/vectors/adapter.wrap.envelope_v0_1/negative/wrong_schema_version.json +11 -0
- package/docs/prov-spec/spec/vectors/engine.extract.evidence.json_pointer/expected.json +24 -0
- package/docs/prov-spec/spec/vectors/engine.extract.evidence.json_pointer/input.json +8 -0
- package/docs/prov-spec/spec/vectors/integrity.digest.sha256/expected.json +7 -0
- package/docs/prov-spec/spec/vectors/integrity.digest.sha256/input.json +1 -0
- package/docs/prov-spec/spec/vectors/integrity.digest.sha256/negative/non_hex_chars.json +16 -0
- package/docs/prov-spec/spec/vectors/integrity.digest.sha256/negative/uppercase_hex.json +16 -0
- package/docs/prov-spec/spec/vectors/integrity.digest.sha256/negative/wrong_length.json +16 -0
- package/docs/prov-spec/spec/vectors/method_id_syntax/negative/hyphen_separator.json +8 -0
- package/docs/prov-spec/spec/vectors/method_id_syntax/negative/reserved_namespace.json +8 -0
- package/docs/prov-spec/spec/vectors/method_id_syntax/negative/starts_with_digit.json +8 -0
- package/docs/prov-spec/spec/vectors/method_id_syntax/negative/uppercase.json +8 -0
- package/docs/prov-spec/spec/vectors/method_id_syntax/positive/valid_ids.json +18 -0
- package/docs/prov-spec/tools/python/prov_validator.py +428 -0
- package/examples/a11y-demo-site/.github/workflows/a11y-artifacts.yml +81 -0
- package/examples/a11y-demo-site/.github/workflows/a11y.yml +34 -0
- package/examples/a11y-demo-site/CODE_OF_CONDUCT.md +129 -0
- package/examples/a11y-demo-site/CONTRIBUTING.md +83 -0
- package/examples/a11y-demo-site/LICENSE +21 -0
- package/examples/a11y-demo-site/README.md +155 -0
- package/examples/a11y-demo-site/html/contact.html +15 -0
- package/examples/a11y-demo-site/html/index.html +20 -0
- package/examples/a11y-demo-site/scripts/a11y.sh +20 -0
- package/package.json +26 -0
- package/src/a11y-assist/.github/workflows/publish.yml +52 -0
- package/src/a11y-assist/.github/workflows/test.yml +30 -0
- package/src/a11y-assist/A11Y_ASSIST_TEST_COVERAGE_REQUIREMENTS.md +104 -0
- package/src/a11y-assist/CODE_OF_CONDUCT.md +129 -0
- package/src/a11y-assist/CONTRIBUTING.md +98 -0
- package/src/a11y-assist/ENGINE.md +363 -0
- package/src/a11y-assist/LICENSE +21 -0
- package/src/a11y-assist/PRESS_RELEASE.md +71 -0
- package/src/a11y-assist/QUICKSTART.md +101 -0
- package/src/a11y-assist/README.md +192 -0
- package/src/a11y-assist/RELEASE_NOTES.md +319 -0
- package/src/a11y-assist/a11y_assist/__init__.py +3 -0
- package/src/a11y-assist/a11y_assist/cli.py +599 -0
- package/src/a11y-assist/a11y_assist/from_cli_error.py +149 -0
- package/src/a11y-assist/a11y_assist/guard.py +444 -0
- package/src/a11y-assist/a11y_assist/ingest.py +407 -0
- package/src/a11y-assist/a11y_assist/methods.py +137 -0
- package/src/a11y-assist/a11y_assist/parse_raw.py +71 -0
- package/src/a11y-assist/a11y_assist/profiles/__init__.py +29 -0
- package/src/a11y-assist/a11y_assist/profiles/cognitive_load.py +245 -0
- package/src/a11y-assist/a11y_assist/profiles/cognitive_load_render.py +86 -0
- package/src/a11y-assist/a11y_assist/profiles/dyslexia.py +144 -0
- package/src/a11y-assist/a11y_assist/profiles/dyslexia_render.py +77 -0
- package/src/a11y-assist/a11y_assist/profiles/plain_language.py +119 -0
- package/src/a11y-assist/a11y_assist/profiles/plain_language_render.py +66 -0
- package/src/a11y-assist/a11y_assist/profiles/screen_reader.py +348 -0
- package/src/a11y-assist/a11y_assist/profiles/screen_reader_render.py +89 -0
- package/src/a11y-assist/a11y_assist/render.py +95 -0
- package/src/a11y-assist/a11y_assist/schemas/assist.request.schema.v0.1.json +52 -0
- package/src/a11y-assist/a11y_assist/schemas/assist.response.schema.v0.1.json +70 -0
- package/src/a11y-assist/a11y_assist/schemas/cli.error.schema.v0.1.json +78 -0
- package/src/a11y-assist/a11y_assist/storage.py +31 -0
- package/src/a11y-assist/pyproject.toml +60 -0
- package/src/a11y-assist/tests/__init__.py +1 -0
- package/src/a11y-assist/tests/fixtures/base_inputs/cli_error_high.json +18 -0
- package/src/a11y-assist/tests/fixtures/base_inputs/cli_error_medium.json +16 -0
- package/src/a11y-assist/tests/fixtures/base_inputs/raw_text_low.txt +3 -0
- package/src/a11y-assist/tests/fixtures/cli_error_good.json +9 -0
- package/src/a11y-assist/tests/fixtures/cli_error_missing_id.json +7 -0
- package/src/a11y-assist/tests/fixtures/cli_error_string_format.json +7 -0
- package/src/a11y-assist/tests/fixtures/expected/cognitive_load_high.txt +20 -0
- package/src/a11y-assist/tests/fixtures/expected/dyslexia_high.txt +20 -0
- package/src/a11y-assist/tests/fixtures/expected/lowvision_high.txt +18 -0
- package/src/a11y-assist/tests/fixtures/expected/plain_language_high.txt +14 -0
- package/src/a11y-assist/tests/fixtures/expected/screen_reader_high.txt +19 -0
- package/src/a11y-assist/tests/fixtures/golden_screen_reader_cli_error.txt +16 -0
- package/src/a11y-assist/tests/fixtures/golden_screen_reader_raw_no_id.txt +14 -0
- package/src/a11y-assist/tests/fixtures/golden_screen_reader_raw_with_id.txt +14 -0
- package/src/a11y-assist/tests/fixtures/raw_good.txt +11 -0
- package/src/a11y-assist/tests/fixtures/raw_no_id.txt +2 -0
- package/src/a11y-assist/tests/test_cognitive_load.py +469 -0
- package/src/a11y-assist/tests/test_dyslexia.py +337 -0
- package/src/a11y-assist/tests/test_explain.py +74 -0
- package/src/a11y-assist/tests/test_golden.py +127 -0
- package/src/a11y-assist/tests/test_guard.py +819 -0
- package/src/a11y-assist/tests/test_guard_integration.py +457 -0
- package/src/a11y-assist/tests/test_ingest.py +311 -0
- package/src/a11y-assist/tests/test_methods_metadata.py +236 -0
- package/src/a11y-assist/tests/test_plain_language.py +348 -0
- package/src/a11y-assist/tests/test_render.py +117 -0
- package/src/a11y-assist/tests/test_screen_reader.py +703 -0
- package/src/a11y-assist/tests/test_storage_last.py +61 -0
- package/src/a11y-assist/tests/test_triage.py +86 -0
- package/src/a11y-ci/.github/workflows/ci.yml +43 -0
- package/src/a11y-ci/.github/workflows/test.yml +30 -0
- package/src/a11y-ci/A11Y_CI_TEST_COVERAGE_REQUIREMENTS.md +94 -0
- package/src/a11y-ci/CODE_OF_CONDUCT.md +129 -0
- package/src/a11y-ci/CONTRIBUTING.md +142 -0
- package/src/a11y-ci/LICENSE +21 -0
- package/src/a11y-ci/README.md +105 -0
- package/src/a11y-ci/a11y_ci/__init__.py +3 -0
- package/src/a11y-ci/a11y_ci/allowlist.py +83 -0
- package/src/a11y-ci/a11y_ci/cli.py +145 -0
- package/src/a11y-ci/a11y_ci/gate.py +131 -0
- package/src/a11y-ci/a11y_ci/render.py +48 -0
- package/src/a11y-ci/a11y_ci/schemas/allowlist.schema.json +24 -0
- package/src/a11y-ci/a11y_ci/scorecard.py +99 -0
- package/src/a11y-ci/npm/package.json +35 -0
- package/src/a11y-ci/pyproject.toml +64 -0
- package/src/a11y-ci/tests/__init__.py +1 -0
- package/src/a11y-ci/tests/fixtures/allowlist_expired.json +10 -0
- package/src/a11y-ci/tests/fixtures/allowlist_ok.json +10 -0
- package/src/a11y-ci/tests/fixtures/baseline_ok.json +7 -0
- package/src/a11y-ci/tests/fixtures/current_fail.json +6 -0
- package/src/a11y-ci/tests/fixtures/current_ok.json +6 -0
- package/src/a11y-ci/tests/fixtures/current_regresses.json +7 -0
- package/src/a11y-ci/tests/test_gate.py +134 -0
- package/src/a11y-evidence-engine/.github/workflows/ci.yml +53 -0
- package/src/a11y-evidence-engine/CODE_OF_CONDUCT.md +129 -0
- package/src/a11y-evidence-engine/CONTRIBUTING.md +128 -0
- package/src/a11y-evidence-engine/LICENSE +21 -0
- package/src/a11y-evidence-engine/README.md +71 -0
- package/src/a11y-evidence-engine/bin/a11y-engine.js +11 -0
- package/src/a11y-evidence-engine/fixtures/bad/button-no-name.html +30 -0
- package/src/a11y-evidence-engine/fixtures/bad/img-missing-alt.html +19 -0
- package/src/a11y-evidence-engine/fixtures/bad/input-missing-label.html +26 -0
- package/src/a11y-evidence-engine/fixtures/bad/missing-lang.html +11 -0
- package/src/a11y-evidence-engine/fixtures/good/index.html +29 -0
- package/src/a11y-evidence-engine/package-lock.json +109 -0
- package/src/a11y-evidence-engine/package.json +45 -0
- package/src/a11y-evidence-engine/src/cli.js +74 -0
- package/src/a11y-evidence-engine/src/evidence/canonicalize.js +52 -0
- package/src/a11y-evidence-engine/src/evidence/json_pointer.js +34 -0
- package/src/a11y-evidence-engine/src/evidence/prov_emit.js +153 -0
- package/src/a11y-evidence-engine/src/fswalk.js +56 -0
- package/src/a11y-evidence-engine/src/html_parse.js +117 -0
- package/src/a11y-evidence-engine/src/ids.js +53 -0
- package/src/a11y-evidence-engine/src/rules/document_missing_lang.js +50 -0
- package/src/a11y-evidence-engine/src/rules/form_control_missing_label.js +105 -0
- package/src/a11y-evidence-engine/src/rules/img_missing_alt.js +77 -0
- package/src/a11y-evidence-engine/src/rules/index.js +37 -0
- package/src/a11y-evidence-engine/src/rules/interactive_missing_name.js +129 -0
- package/src/a11y-evidence-engine/src/scan.js +128 -0
- package/src/a11y-evidence-engine/test/scan.test.js +149 -0
- package/src/a11y-evidence-engine/test/vectors.test.js +200 -0
- package/src/a11y-lint/.github/workflows/ci.yml +46 -0
- package/src/a11y-lint/.github/workflows/test.yml +34 -0
- package/src/a11y-lint/CODE_OF_CONDUCT.md +129 -0
- package/src/a11y-lint/CONTRIBUTING.md +70 -0
- package/src/a11y-lint/GOVERNANCE.md +57 -0
- package/src/a11y-lint/LICENSE +21 -0
- package/src/a11y-lint/PRESS_RELEASE.md +50 -0
- package/src/a11y-lint/README.md +276 -0
- package/src/a11y-lint/RELEASE_NOTES.md +57 -0
- package/src/a11y-lint/RELEASING.md +57 -0
- package/src/a11y-lint/a11y_lint/__init__.py +64 -0
- package/src/a11y-lint/a11y_lint/cli.py +319 -0
- package/src/a11y-lint/a11y_lint/errors.py +252 -0
- package/src/a11y-lint/a11y_lint/render.py +293 -0
- package/src/a11y-lint/a11y_lint/report_md.py +289 -0
- package/src/a11y-lint/a11y_lint/scan_cli_text.py +434 -0
- package/src/a11y-lint/a11y_lint/schemas/cli.error.schema.v0.1.json +83 -0
- package/src/a11y-lint/a11y_lint/scorecard.py +244 -0
- package/src/a11y-lint/a11y_lint/validate.py +225 -0
- package/src/a11y-lint/pyproject.toml +75 -0
- package/src/a11y-lint/tests/__init__.py +1 -0
- package/src/a11y-lint/tests/test_cli.py +200 -0
- package/src/a11y-lint/tests/test_errors.py +188 -0
- package/src/a11y-lint/tests/test_render.py +202 -0
- package/src/a11y-lint/tests/test_report_md.py +188 -0
- package/src/a11y-lint/tests/test_scan_cli_text.py +290 -0
- package/src/a11y-lint/tests/test_scorecard.py +195 -0
- package/src/a11y-lint/tests/test_validate.py +257 -0
- package/src/a11y-mcp-tools/.github/workflows/ci.yml +53 -0
- package/src/a11y-mcp-tools/CODE_OF_CONDUCT.md +129 -0
- package/src/a11y-mcp-tools/CONTRIBUTING.md +136 -0
- package/src/a11y-mcp-tools/LICENSE +21 -0
- package/src/a11y-mcp-tools/PROV_METHODS_CATALOG.md +104 -0
- package/src/a11y-mcp-tools/README.md +168 -0
- package/src/a11y-mcp-tools/bin/cli.js +452 -0
- package/src/a11y-mcp-tools/bin/server.js +244 -0
- package/src/a11y-mcp-tools/fixtures/requests/a11y.diagnose.ok.json +27 -0
- package/src/a11y-mcp-tools/fixtures/requests/a11y.evidence.ok.json +25 -0
- package/src/a11y-mcp-tools/fixtures/responses/a11y.diagnose.ok.json +139 -0
- package/src/a11y-mcp-tools/fixtures/responses/a11y.diagnose.provenance_fail.json +13 -0
- package/src/a11y-mcp-tools/fixtures/responses/a11y.evidence.ok.json +88 -0
- package/src/a11y-mcp-tools/package-lock.json +189 -0
- package/src/a11y-mcp-tools/package.json +49 -0
- package/src/a11y-mcp-tools/src/envelope.js +197 -0
- package/src/a11y-mcp-tools/src/index.js +9 -0
- package/src/a11y-mcp-tools/src/schemas/artifact.js +85 -0
- package/src/a11y-mcp-tools/src/schemas/diagnosis.schema.v0.1.json +137 -0
- package/src/a11y-mcp-tools/src/schemas/envelope.schema.v0.1.json +108 -0
- package/src/a11y-mcp-tools/src/schemas/evidence.bundle.schema.v0.1.json +129 -0
- package/src/a11y-mcp-tools/src/schemas/evidence.js +97 -0
- package/src/a11y-mcp-tools/src/schemas/index.js +11 -0
- package/src/a11y-mcp-tools/src/schemas/provenance.js +140 -0
- package/src/a11y-mcp-tools/src/schemas/tools/a11y.diagnose.request.schema.v0.1.json +77 -0
- package/src/a11y-mcp-tools/src/schemas/tools/a11y.diagnose.response.schema.v0.1.json +50 -0
- package/src/a11y-mcp-tools/src/schemas/tools/a11y.evidence.request.schema.v0.1.json +120 -0
- package/src/a11y-mcp-tools/src/schemas/tools/a11y.evidence.response.schema.v0.1.json +50 -0
- package/src/a11y-mcp-tools/src/tools/diagnose.js +597 -0
- package/src/a11y-mcp-tools/src/tools/evidence.js +481 -0
- package/src/a11y-mcp-tools/src/tools/index.js +10 -0
- package/src/a11y-mcp-tools/test/contract.test.mjs +154 -0
- package/src/a11y-mcp-tools/test/diagnose.test.js +485 -0
- package/src/a11y-mcp-tools/test/evidence.test.js +183 -0
- package/src/a11y-mcp-tools/test/schema.test.js +327 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { Parser } = require("htmlparser2");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse HTML and build a flat nodes[] array in document order.
|
|
7
|
+
* Each node has a stable index for JSON pointer references.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} html - Raw HTML content
|
|
10
|
+
* @returns {{ nodes: Array, root: Object|null }}
|
|
11
|
+
*/
|
|
12
|
+
function parseHtml(html) {
|
|
13
|
+
const nodes = [];
|
|
14
|
+
const stack = [];
|
|
15
|
+
let root = null;
|
|
16
|
+
|
|
17
|
+
const parser = new Parser(
|
|
18
|
+
{
|
|
19
|
+
onopentag(name, attrs) {
|
|
20
|
+
const node = {
|
|
21
|
+
type: "element",
|
|
22
|
+
tagName: name.toLowerCase(),
|
|
23
|
+
attrs: { ...attrs },
|
|
24
|
+
children: [],
|
|
25
|
+
parent: stack.length > 0 ? stack[stack.length - 1] : null,
|
|
26
|
+
index: nodes.length,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
nodes.push(node);
|
|
30
|
+
|
|
31
|
+
if (node.parent) {
|
|
32
|
+
node.parent.children.push(node);
|
|
33
|
+
} else {
|
|
34
|
+
root = node;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
stack.push(node);
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
ontext(text) {
|
|
41
|
+
// Only capture non-whitespace text for accessibility analysis
|
|
42
|
+
const trimmed = text.trim();
|
|
43
|
+
if (trimmed && stack.length > 0) {
|
|
44
|
+
const textNode = {
|
|
45
|
+
type: "text",
|
|
46
|
+
content: text,
|
|
47
|
+
trimmed: trimmed,
|
|
48
|
+
parent: stack[stack.length - 1],
|
|
49
|
+
index: nodes.length,
|
|
50
|
+
};
|
|
51
|
+
nodes.push(textNode);
|
|
52
|
+
stack[stack.length - 1].children.push(textNode);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
onclosetag() {
|
|
57
|
+
stack.pop();
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
lowerCaseTags: true,
|
|
62
|
+
lowerCaseAttributeNames: true,
|
|
63
|
+
decodeEntities: true,
|
|
64
|
+
}
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
parser.write(html);
|
|
68
|
+
parser.end();
|
|
69
|
+
|
|
70
|
+
return { nodes, root };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Get the text content of an element (concatenated child text nodes).
|
|
75
|
+
* @param {Object} node - Element node
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
function getTextContent(node) {
|
|
79
|
+
if (!node || !node.children) return "";
|
|
80
|
+
|
|
81
|
+
let text = "";
|
|
82
|
+
for (const child of node.children) {
|
|
83
|
+
if (child.type === "text") {
|
|
84
|
+
text += child.content;
|
|
85
|
+
} else if (child.type === "element") {
|
|
86
|
+
text += getTextContent(child);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return text.trim();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Find all elements matching a predicate.
|
|
94
|
+
* @param {Array} nodes - Flat nodes array
|
|
95
|
+
* @param {Function} predicate - (node) => boolean
|
|
96
|
+
* @returns {Array}
|
|
97
|
+
*/
|
|
98
|
+
function findElements(nodes, predicate) {
|
|
99
|
+
return nodes.filter((n) => n.type === "element" && predicate(n));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Find element by ID.
|
|
104
|
+
* @param {Array} nodes - Flat nodes array
|
|
105
|
+
* @param {string} id - Element ID
|
|
106
|
+
* @returns {Object|null}
|
|
107
|
+
*/
|
|
108
|
+
function getElementById(nodes, id) {
|
|
109
|
+
return nodes.find((n) => n.type === "element" && n.attrs.id === id) || null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = {
|
|
113
|
+
parseHtml,
|
|
114
|
+
getTextContent,
|
|
115
|
+
findElements,
|
|
116
|
+
getElementById,
|
|
117
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate deterministic finding IDs.
|
|
5
|
+
* Findings are sorted by (file, rule_id, pointer) then numbered.
|
|
6
|
+
*
|
|
7
|
+
* @param {Array} findings - Raw findings array
|
|
8
|
+
* @returns {Array} Findings with assigned finding_id
|
|
9
|
+
*/
|
|
10
|
+
function assignFindingIds(findings) {
|
|
11
|
+
// Sort for deterministic ordering
|
|
12
|
+
const sorted = [...findings].sort((a, b) => {
|
|
13
|
+
// First by file
|
|
14
|
+
const fileCompare = a.location.file.localeCompare(b.location.file);
|
|
15
|
+
if (fileCompare !== 0) return fileCompare;
|
|
16
|
+
|
|
17
|
+
// Then by rule_id
|
|
18
|
+
const ruleCompare = a.rule_id.localeCompare(b.rule_id);
|
|
19
|
+
if (ruleCompare !== 0) return ruleCompare;
|
|
20
|
+
|
|
21
|
+
// Then by pointer (numeric extraction for proper sorting)
|
|
22
|
+
const aIndex = extractPointerIndex(a.location.json_pointer);
|
|
23
|
+
const bIndex = extractPointerIndex(b.location.json_pointer);
|
|
24
|
+
return aIndex - bIndex;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Assign sequential IDs
|
|
28
|
+
return sorted.map((finding, index) => ({
|
|
29
|
+
...finding,
|
|
30
|
+
finding_id: formatFindingId(index + 1),
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Extract numeric index from JSON pointer.
|
|
36
|
+
* @param {string} pointer - e.g., "/nodes/12"
|
|
37
|
+
* @returns {number}
|
|
38
|
+
*/
|
|
39
|
+
function extractPointerIndex(pointer) {
|
|
40
|
+
const match = pointer.match(/\/nodes\/(\d+)/);
|
|
41
|
+
return match ? parseInt(match[1], 10) : 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Format finding ID with zero-padding.
|
|
46
|
+
* @param {number} num - Finding number (1-based)
|
|
47
|
+
* @returns {string} e.g., "finding-0001"
|
|
48
|
+
*/
|
|
49
|
+
function formatFindingId(num) {
|
|
50
|
+
return `finding-${String(num).padStart(4, "0")}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { assignFindingIds, formatFindingId };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { findElements } = require("../html_parse.js");
|
|
4
|
+
|
|
5
|
+
const RULE_ID = "html.document.missing_lang";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Check for <html> element missing lang attribute.
|
|
9
|
+
* WCAG 3.1.1 - Language of Page (Level A)
|
|
10
|
+
*/
|
|
11
|
+
function run(nodes, context) {
|
|
12
|
+
const findings = [];
|
|
13
|
+
|
|
14
|
+
const htmlElements = findElements(nodes, (n) => n.tagName === "html");
|
|
15
|
+
|
|
16
|
+
for (const node of htmlElements) {
|
|
17
|
+
const lang = node.attrs.lang;
|
|
18
|
+
|
|
19
|
+
if (!lang || lang.trim() === "") {
|
|
20
|
+
findings.push({
|
|
21
|
+
rule_id: RULE_ID,
|
|
22
|
+
severity: "error",
|
|
23
|
+
confidence: 1.0,
|
|
24
|
+
message: "Document is missing a lang attribute on the <html> element.",
|
|
25
|
+
location: {
|
|
26
|
+
file: context.relativePath,
|
|
27
|
+
json_pointer: `/nodes/${node.index}`,
|
|
28
|
+
},
|
|
29
|
+
evidence: {
|
|
30
|
+
tagName: node.tagName,
|
|
31
|
+
attrs: filterAttrs(node.attrs, ["lang"]),
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return findings;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function filterAttrs(attrs, keys) {
|
|
41
|
+
const filtered = {};
|
|
42
|
+
for (const key of keys) {
|
|
43
|
+
if (key in attrs) {
|
|
44
|
+
filtered[key] = attrs[key];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return filtered;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { id: RULE_ID, run };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { findElements, getElementById } = require("../html_parse.js");
|
|
4
|
+
|
|
5
|
+
const RULE_ID = "html.form_control.missing_label";
|
|
6
|
+
|
|
7
|
+
const FORM_CONTROLS = ["input", "select", "textarea"];
|
|
8
|
+
const EXEMPT_INPUT_TYPES = ["hidden", "submit", "reset", "button", "image"];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check for form controls missing associated labels.
|
|
12
|
+
* WCAG 1.3.1 - Info and Relationships (Level A)
|
|
13
|
+
* WCAG 4.1.2 - Name, Role, Value (Level A)
|
|
14
|
+
*
|
|
15
|
+
* A form control needs one of:
|
|
16
|
+
* - Associated <label for="...">
|
|
17
|
+
* - aria-label attribute
|
|
18
|
+
* - aria-labelledby attribute (pointing to existing element)
|
|
19
|
+
*/
|
|
20
|
+
function run(nodes, context) {
|
|
21
|
+
const findings = [];
|
|
22
|
+
|
|
23
|
+
// Find all label elements for lookup
|
|
24
|
+
const labels = findElements(nodes, (n) => n.tagName === "label");
|
|
25
|
+
const labelForIds = new Set(
|
|
26
|
+
labels.map((l) => l.attrs.for).filter((f) => f)
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// Check form controls
|
|
30
|
+
const controls = findElements(nodes, (n) => FORM_CONTROLS.includes(n.tagName));
|
|
31
|
+
|
|
32
|
+
for (const node of controls) {
|
|
33
|
+
// Skip exempt input types
|
|
34
|
+
if (node.tagName === "input") {
|
|
35
|
+
const type = (node.attrs.type || "text").toLowerCase();
|
|
36
|
+
if (EXEMPT_INPUT_TYPES.includes(type)) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!hasAccessibleLabel(node, labelForIds, nodes)) {
|
|
42
|
+
findings.push({
|
|
43
|
+
rule_id: RULE_ID,
|
|
44
|
+
severity: "error",
|
|
45
|
+
confidence: 0.95,
|
|
46
|
+
message: `Form control <${node.tagName}> is missing an associated label.`,
|
|
47
|
+
location: {
|
|
48
|
+
file: context.relativePath,
|
|
49
|
+
json_pointer: `/nodes/${node.index}`,
|
|
50
|
+
},
|
|
51
|
+
evidence: {
|
|
52
|
+
tagName: node.tagName,
|
|
53
|
+
attrs: filterAttrs(node.attrs, [
|
|
54
|
+
"id",
|
|
55
|
+
"name",
|
|
56
|
+
"type",
|
|
57
|
+
"aria-label",
|
|
58
|
+
"aria-labelledby",
|
|
59
|
+
]),
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return findings;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check if a form control has an accessible label.
|
|
70
|
+
*/
|
|
71
|
+
function hasAccessibleLabel(node, labelForIds, nodes) {
|
|
72
|
+
// Check aria-label
|
|
73
|
+
if (node.attrs["aria-label"] && node.attrs["aria-label"].trim()) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check aria-labelledby (must point to existing element)
|
|
78
|
+
if (node.attrs["aria-labelledby"]) {
|
|
79
|
+
const ids = node.attrs["aria-labelledby"].split(/\s+/);
|
|
80
|
+
for (const id of ids) {
|
|
81
|
+
if (getElementById(nodes, id)) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Check for associated label via 'for' attribute
|
|
88
|
+
if (node.attrs.id && labelForIds.has(node.attrs.id)) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function filterAttrs(attrs, keys) {
|
|
96
|
+
const filtered = {};
|
|
97
|
+
for (const key of keys) {
|
|
98
|
+
if (key in attrs) {
|
|
99
|
+
filtered[key] = attrs[key];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return filtered;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { id: RULE_ID, run };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { findElements } = require("../html_parse.js");
|
|
4
|
+
|
|
5
|
+
const RULE_ID = "html.img.missing_alt";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Check for <img> elements missing alt attribute.
|
|
9
|
+
* WCAG 1.1.1 - Non-text Content (Level A)
|
|
10
|
+
*
|
|
11
|
+
* Exceptions:
|
|
12
|
+
* - role="presentation" or role="none"
|
|
13
|
+
* - aria-hidden="true"
|
|
14
|
+
*/
|
|
15
|
+
function run(nodes, context) {
|
|
16
|
+
const findings = [];
|
|
17
|
+
|
|
18
|
+
const imgElements = findElements(nodes, (n) => n.tagName === "img");
|
|
19
|
+
|
|
20
|
+
for (const node of imgElements) {
|
|
21
|
+
// Skip decorative images
|
|
22
|
+
if (isDecorativeImage(node)) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const alt = node.attrs.alt;
|
|
27
|
+
|
|
28
|
+
// Missing alt attribute entirely
|
|
29
|
+
if (alt === undefined) {
|
|
30
|
+
findings.push({
|
|
31
|
+
rule_id: RULE_ID,
|
|
32
|
+
severity: "error",
|
|
33
|
+
confidence: 0.98,
|
|
34
|
+
message: "Image element is missing alt text.",
|
|
35
|
+
location: {
|
|
36
|
+
file: context.relativePath,
|
|
37
|
+
json_pointer: `/nodes/${node.index}`,
|
|
38
|
+
},
|
|
39
|
+
evidence: {
|
|
40
|
+
tagName: node.tagName,
|
|
41
|
+
attrs: filterAttrs(node.attrs, ["src", "alt", "role", "aria-hidden"]),
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return findings;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Check if image is marked as decorative.
|
|
52
|
+
*/
|
|
53
|
+
function isDecorativeImage(node) {
|
|
54
|
+
const role = node.attrs.role;
|
|
55
|
+
if (role === "presentation" || role === "none") {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ariaHidden = node.attrs["aria-hidden"];
|
|
60
|
+
if (ariaHidden === "true") {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function filterAttrs(attrs, keys) {
|
|
68
|
+
const filtered = {};
|
|
69
|
+
for (const key of keys) {
|
|
70
|
+
if (key in attrs) {
|
|
71
|
+
filtered[key] = attrs[key];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return filtered;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { id: RULE_ID, run };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const imgMissingAlt = require("./img_missing_alt.js");
|
|
4
|
+
const formControlMissingLabel = require("./form_control_missing_label.js");
|
|
5
|
+
const interactiveMissingName = require("./interactive_missing_name.js");
|
|
6
|
+
const documentMissingLang = require("./document_missing_lang.js");
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* All available rules.
|
|
10
|
+
* Each rule exports: { id, run(nodes, context) => findings[] }
|
|
11
|
+
*/
|
|
12
|
+
const rules = [
|
|
13
|
+
documentMissingLang,
|
|
14
|
+
imgMissingAlt,
|
|
15
|
+
formControlMissingLabel,
|
|
16
|
+
interactiveMissingName,
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Run all rules against parsed HTML nodes.
|
|
21
|
+
*
|
|
22
|
+
* @param {Array} nodes - Flat nodes array from parseHtml
|
|
23
|
+
* @param {Object} context - { filePath, relativePath }
|
|
24
|
+
* @returns {Array} Raw findings (without finding_id assigned)
|
|
25
|
+
*/
|
|
26
|
+
function runRules(nodes, context) {
|
|
27
|
+
const findings = [];
|
|
28
|
+
|
|
29
|
+
for (const rule of rules) {
|
|
30
|
+
const ruleFindings = rule.run(nodes, context);
|
|
31
|
+
findings.push(...ruleFindings);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return findings;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { rules, runRules };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { findElements, getTextContent, getElementById } = require("../html_parse.js");
|
|
4
|
+
|
|
5
|
+
const RULE_ID = "html.interactive.missing_name";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Check for interactive elements missing accessible names.
|
|
9
|
+
* WCAG 4.1.2 - Name, Role, Value (Level A)
|
|
10
|
+
*
|
|
11
|
+
* Checks:
|
|
12
|
+
* - <button> elements
|
|
13
|
+
* - <a href="..."> elements (links)
|
|
14
|
+
*
|
|
15
|
+
* An accessible name can come from:
|
|
16
|
+
* - Text content
|
|
17
|
+
* - aria-label
|
|
18
|
+
* - aria-labelledby
|
|
19
|
+
* - title attribute (fallback)
|
|
20
|
+
*/
|
|
21
|
+
function run(nodes, context) {
|
|
22
|
+
const findings = [];
|
|
23
|
+
|
|
24
|
+
// Check buttons
|
|
25
|
+
const buttons = findElements(nodes, (n) => n.tagName === "button");
|
|
26
|
+
for (const node of buttons) {
|
|
27
|
+
if (!hasAccessibleName(node, nodes)) {
|
|
28
|
+
findings.push({
|
|
29
|
+
rule_id: RULE_ID,
|
|
30
|
+
severity: "error",
|
|
31
|
+
confidence: 0.95,
|
|
32
|
+
message: "Button element is missing an accessible name.",
|
|
33
|
+
location: {
|
|
34
|
+
file: context.relativePath,
|
|
35
|
+
json_pointer: `/nodes/${node.index}`,
|
|
36
|
+
},
|
|
37
|
+
evidence: {
|
|
38
|
+
tagName: node.tagName,
|
|
39
|
+
attrs: filterAttrs(node.attrs, [
|
|
40
|
+
"id",
|
|
41
|
+
"type",
|
|
42
|
+
"aria-label",
|
|
43
|
+
"aria-labelledby",
|
|
44
|
+
"title",
|
|
45
|
+
]),
|
|
46
|
+
textContent: getTextContent(node).substring(0, 50),
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Check links (anchors with href)
|
|
53
|
+
const links = findElements(
|
|
54
|
+
nodes,
|
|
55
|
+
(n) => n.tagName === "a" && n.attrs.href !== undefined
|
|
56
|
+
);
|
|
57
|
+
for (const node of links) {
|
|
58
|
+
if (!hasAccessibleName(node, nodes)) {
|
|
59
|
+
findings.push({
|
|
60
|
+
rule_id: RULE_ID,
|
|
61
|
+
severity: "error",
|
|
62
|
+
confidence: 0.95,
|
|
63
|
+
message: "Link element is missing an accessible name.",
|
|
64
|
+
location: {
|
|
65
|
+
file: context.relativePath,
|
|
66
|
+
json_pointer: `/nodes/${node.index}`,
|
|
67
|
+
},
|
|
68
|
+
evidence: {
|
|
69
|
+
tagName: node.tagName,
|
|
70
|
+
attrs: filterAttrs(node.attrs, [
|
|
71
|
+
"href",
|
|
72
|
+
"aria-label",
|
|
73
|
+
"aria-labelledby",
|
|
74
|
+
"title",
|
|
75
|
+
]),
|
|
76
|
+
textContent: getTextContent(node).substring(0, 50),
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return findings;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Check if an element has an accessible name.
|
|
87
|
+
*/
|
|
88
|
+
function hasAccessibleName(node, nodes) {
|
|
89
|
+
// Check aria-label
|
|
90
|
+
if (node.attrs["aria-label"] && node.attrs["aria-label"].trim()) {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Check aria-labelledby
|
|
95
|
+
if (node.attrs["aria-labelledby"]) {
|
|
96
|
+
const ids = node.attrs["aria-labelledby"].split(/\s+/);
|
|
97
|
+
for (const id of ids) {
|
|
98
|
+
const labelElement = getElementById(nodes, id);
|
|
99
|
+
if (labelElement && getTextContent(labelElement).trim()) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Check text content
|
|
106
|
+
const textContent = getTextContent(node).trim();
|
|
107
|
+
if (textContent) {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check title as fallback
|
|
112
|
+
if (node.attrs.title && node.attrs.title.trim()) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function filterAttrs(attrs, keys) {
|
|
120
|
+
const filtered = {};
|
|
121
|
+
for (const key of keys) {
|
|
122
|
+
if (key in attrs) {
|
|
123
|
+
filtered[key] = attrs[key];
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return filtered;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { id: RULE_ID, run };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { walkHtmlFiles } = require("./fswalk.js");
|
|
6
|
+
const { parseHtml } = require("./html_parse.js");
|
|
7
|
+
const { runRules } = require("./rules/index.js");
|
|
8
|
+
const { assignFindingIds } = require("./ids.js");
|
|
9
|
+
const { emitProvenance } = require("./evidence/prov_emit.js");
|
|
10
|
+
|
|
11
|
+
const ENGINE_NAME = "a11y-evidence-engine";
|
|
12
|
+
const ENGINE_VERSION = "0.1.0";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Scan HTML files and produce findings with provenance.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} targetPath - File or directory to scan
|
|
18
|
+
* @param {string} outDir - Output directory
|
|
19
|
+
* @returns {Object} Scan result with summary
|
|
20
|
+
*/
|
|
21
|
+
async function scan(targetPath, outDir) {
|
|
22
|
+
const resolvedTarget = path.resolve(targetPath);
|
|
23
|
+
const resolvedOut = path.resolve(outDir);
|
|
24
|
+
|
|
25
|
+
// Gather HTML files
|
|
26
|
+
const files = walkHtmlFiles(resolvedTarget);
|
|
27
|
+
|
|
28
|
+
if (files.length === 0) {
|
|
29
|
+
throw new Error(`No HTML files found in: ${resolvedTarget}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Collect all findings
|
|
33
|
+
const allFindings = [];
|
|
34
|
+
const baseDir = fs.statSync(resolvedTarget).isDirectory()
|
|
35
|
+
? resolvedTarget
|
|
36
|
+
: path.dirname(resolvedTarget);
|
|
37
|
+
|
|
38
|
+
for (const filePath of files) {
|
|
39
|
+
const html = fs.readFileSync(filePath, "utf8");
|
|
40
|
+
const { nodes } = parseHtml(html);
|
|
41
|
+
|
|
42
|
+
const relativePath = path.relative(baseDir, filePath).replace(/\\/g, "/");
|
|
43
|
+
|
|
44
|
+
const context = {
|
|
45
|
+
filePath,
|
|
46
|
+
relativePath,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const findings = runRules(nodes, context);
|
|
50
|
+
allFindings.push(...findings);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Assign deterministic IDs
|
|
54
|
+
const numberedFindings = assignFindingIds(allFindings);
|
|
55
|
+
|
|
56
|
+
// Create output directory
|
|
57
|
+
fs.mkdirSync(resolvedOut, { recursive: true });
|
|
58
|
+
|
|
59
|
+
// Generate timestamp for all provenance records (deterministic within scan)
|
|
60
|
+
const timestamp = new Date().toISOString();
|
|
61
|
+
|
|
62
|
+
// Emit provenance for each finding
|
|
63
|
+
const findingsWithRefs = [];
|
|
64
|
+
|
|
65
|
+
for (const finding of numberedFindings) {
|
|
66
|
+
const provDir = path.join(resolvedOut, "provenance", finding.finding_id);
|
|
67
|
+
fs.mkdirSync(provDir, { recursive: true });
|
|
68
|
+
|
|
69
|
+
const { record, digest, envelope } = emitProvenance(finding, {
|
|
70
|
+
engineVersion: ENGINE_VERSION,
|
|
71
|
+
timestamp,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Write provenance files
|
|
75
|
+
fs.writeFileSync(
|
|
76
|
+
path.join(provDir, "record.json"),
|
|
77
|
+
JSON.stringify(record, null, 2)
|
|
78
|
+
);
|
|
79
|
+
fs.writeFileSync(
|
|
80
|
+
path.join(provDir, "digest.json"),
|
|
81
|
+
JSON.stringify(digest, null, 2)
|
|
82
|
+
);
|
|
83
|
+
fs.writeFileSync(
|
|
84
|
+
path.join(provDir, "envelope.json"),
|
|
85
|
+
JSON.stringify(envelope, null, 2)
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// Add evidence_ref to finding (without the raw evidence)
|
|
89
|
+
const { evidence, ...findingWithoutEvidence } = finding;
|
|
90
|
+
findingsWithRefs.push({
|
|
91
|
+
...findingWithoutEvidence,
|
|
92
|
+
evidence_ref: {
|
|
93
|
+
record: `provenance/${finding.finding_id}/record.json`,
|
|
94
|
+
digest: `provenance/${finding.finding_id}/digest.json`,
|
|
95
|
+
envelope: `provenance/${finding.finding_id}/envelope.json`,
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Build summary
|
|
101
|
+
const summary = {
|
|
102
|
+
files_scanned: files.length,
|
|
103
|
+
errors: findingsWithRefs.filter((f) => f.severity === "error").length,
|
|
104
|
+
warnings: findingsWithRefs.filter((f) => f.severity === "warning").length,
|
|
105
|
+
info: findingsWithRefs.filter((f) => f.severity === "info").length,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Build output
|
|
109
|
+
const output = {
|
|
110
|
+
engine: ENGINE_NAME,
|
|
111
|
+
version: ENGINE_VERSION,
|
|
112
|
+
target: {
|
|
113
|
+
path: path.relative(process.cwd(), resolvedTarget).replace(/\\/g, "/") || ".",
|
|
114
|
+
},
|
|
115
|
+
summary,
|
|
116
|
+
findings: findingsWithRefs,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Write findings.json
|
|
120
|
+
fs.writeFileSync(
|
|
121
|
+
path.join(resolvedOut, "findings.json"),
|
|
122
|
+
JSON.stringify(output, null, 2)
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return output;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { scan };
|