mirror of
https://github.com/morluto/rea.git
synced 2026-10-22 05:42:31 -05:00
refactor(javascript): group application workflows and verification (#896)
This commit is contained in:
1 parent
faaee693d5
commit
0804722cdf
129 files changed
+402
-367
No files matched your search
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
import { analyzeJavaScriptApplication } from "../../../dist/application/javascript/JavaScriptApplicationService.js";
|
||||
import {
|
||||
compareApplicationVersionsEvidence,
|
||||
compareJavaScriptExportShapesEvidence,
|
||||
traceApplicationFeatureEvidence,
|
||||
} from "../../../dist/application/javascript/JavaScriptApplicationWorkflowService.js";
|
||||
import { applicationVersionComparisonResultSchema } from "../../../dist/domain/javascriptApplicationVersionComparisonSchemas.js";
|
||||
import { applicationFeatureTraceResultSchema } from "../../../dist/domain/javascriptFeatureTraceSchemas.js";
|
||||
import { javaScriptExportShapeComparisonResultSchema } from "../../../dist/domain/javascriptExportShapeComparisonSchemas.js";
|
||||
import {
|
||||
completeVerifierRun,
|
||||
createVerifierRun,
|
||||
} from "../../lib/verifier-run.mjs";
|
||||
|
||||
const verifierRun = createVerifierRun();
|
||||
|
||||
const options = parseArgs({
|
||||
options: {
|
||||
left: { type: "string" },
|
||||
right: { type: "string" },
|
||||
"seed-kind": { type: "string", default: "module" },
|
||||
"seed-value": { type: "string" },
|
||||
"left-module-path": { type: "string" },
|
||||
"left-export-name": { type: "string" },
|
||||
"right-module-path": { type: "string" },
|
||||
"right-export-name": { type: "string" },
|
||||
},
|
||||
strict: true,
|
||||
allowPositionals: false,
|
||||
}).values;
|
||||
|
||||
if (options.left === undefined || options.right === undefined)
|
||||
throw new Error(
|
||||
"--left and --right must name operator-provided app/ASAR paths",
|
||||
);
|
||||
const [leftPath, rightPath] = await Promise.all([
|
||||
realpath(options.left),
|
||||
realpath(options.right),
|
||||
]);
|
||||
if (leftPath === rightPath)
|
||||
throw new Error("--left and --right must resolve to distinct paths");
|
||||
const [left, right] = await Promise.all([
|
||||
analyze(leftPath),
|
||||
analyze(rightPath),
|
||||
]);
|
||||
const comparison = compareApplicationVersionsEvidence({ left, right });
|
||||
if (!comparison.ok) throw comparison.error;
|
||||
const comparisonResult = applicationVersionComparisonResultSchema.parse(
|
||||
comparison.value.normalized_result,
|
||||
);
|
||||
const seedValue =
|
||||
options["seed-value"] ??
|
||||
left.normalized_result.graph.nodes.find(
|
||||
({ kind }) => kind === "javascript-module",
|
||||
)?.observations[0]?.label;
|
||||
const trace =
|
||||
seedValue === undefined
|
||||
? null
|
||||
: traceApplicationFeatureEvidence({
|
||||
application: left,
|
||||
seed: {
|
||||
kind: options["seed-kind"],
|
||||
value: seedValue,
|
||||
match: "exact",
|
||||
case_sensitive: false,
|
||||
},
|
||||
direction: "both",
|
||||
});
|
||||
if (trace !== null && !trace.ok) throw trace.error;
|
||||
const traceResult =
|
||||
trace === null
|
||||
? null
|
||||
: applicationFeatureTraceResultSchema.parse(trace.value.normalized_result);
|
||||
const exportShapeSelector = resolveExportShapeSelector(left, right, options);
|
||||
const exportShapeComparison =
|
||||
exportShapeSelector === null
|
||||
? null
|
||||
: compareJavaScriptExportShapesEvidence({
|
||||
left,
|
||||
right,
|
||||
...exportShapeSelector,
|
||||
});
|
||||
if (exportShapeComparison !== null && !exportShapeComparison.ok)
|
||||
throw exportShapeComparison.error;
|
||||
const exportShapeResult =
|
||||
exportShapeComparison === null
|
||||
? null
|
||||
: javaScriptExportShapeComparisonResultSchema.parse(
|
||||
exportShapeComparison.value.normalized_result,
|
||||
);
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
verifier_run: await completeVerifierRun(verifierRun),
|
||||
comparison_id: comparisonResult.comparison_id,
|
||||
evidence_id: comparison.value.evidence_id,
|
||||
left: comparisonResult.left,
|
||||
right: comparisonResult.right,
|
||||
summary: comparisonResult.summary,
|
||||
matching: comparisonResult.matching,
|
||||
coverage: comparisonResult.coverage,
|
||||
export_shape_comparison:
|
||||
exportShapeResult === null
|
||||
? null
|
||||
: {
|
||||
comparison_id: exportShapeResult.comparison_id,
|
||||
evidence_id: exportShapeComparison.value.evidence_id,
|
||||
left: exportShapeResult.left,
|
||||
right: exportShapeResult.right,
|
||||
summary: exportShapeResult.summary,
|
||||
changes: exportShapeResult.changes,
|
||||
coverage: exportShapeResult.coverage,
|
||||
runtime_validation: exportShapeResult.runtime_validation,
|
||||
},
|
||||
trace:
|
||||
traceResult === null
|
||||
? null
|
||||
: {
|
||||
trace_id: traceResult.trace_id,
|
||||
evidence_id: trace.value.evidence_id,
|
||||
seed: traceResult.seed,
|
||||
summary: traceResult.summary,
|
||||
coverage: traceResult.coverage,
|
||||
native_handoffs: traceResult.native_handoffs.map((handoff) => ({
|
||||
artifact_sha256: handoff.artifact_sha256,
|
||||
status: handoff.status,
|
||||
requested_exports: handoff.requested_exports,
|
||||
evidence_ids: handoff.evidence_ids,
|
||||
})),
|
||||
},
|
||||
verified: true,
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
function resolveExportShapeSelector(left, right, values) {
|
||||
const explicit = [
|
||||
values["left-module-path"],
|
||||
values["left-export-name"],
|
||||
values["right-module-path"],
|
||||
values["right-export-name"],
|
||||
];
|
||||
if (explicit.some((value) => value !== undefined)) {
|
||||
if (explicit.some((value) => value === undefined))
|
||||
throw new Error(
|
||||
"Explicit export-shape verification requires all left/right module and export selectors",
|
||||
);
|
||||
return {
|
||||
left_module_path: explicit[0],
|
||||
left_export_name: explicit[1],
|
||||
right_module_path: explicit[2],
|
||||
right_export_name: explicit[3],
|
||||
};
|
||||
}
|
||||
const leftSelectors = returnShapeSelectors(left.normalized_result.graph);
|
||||
const rightSelectors = new Set(
|
||||
returnShapeSelectors(right.normalized_result.graph).map(selectorKey),
|
||||
);
|
||||
const common = leftSelectors.find((selector) =>
|
||||
rightSelectors.has(selectorKey(selector)),
|
||||
);
|
||||
return common === undefined
|
||||
? null
|
||||
: {
|
||||
left_module_path: common.modulePath,
|
||||
left_export_name: common.exportName,
|
||||
right_module_path: common.modulePath,
|
||||
right_export_name: common.exportName,
|
||||
};
|
||||
}
|
||||
|
||||
function returnShapeSelectors(graph) {
|
||||
const selectors = [];
|
||||
for (const node of graph.nodes) {
|
||||
for (const observation of node.observations) {
|
||||
const properties = observation.properties;
|
||||
if (
|
||||
properties.semantic_role === "export-return-shapes" &&
|
||||
typeof properties.module_path === "string" &&
|
||||
typeof properties.exported_name === "string"
|
||||
)
|
||||
selectors.push({
|
||||
modulePath: properties.module_path,
|
||||
exportName: properties.exported_name,
|
||||
});
|
||||
}
|
||||
}
|
||||
return selectors.sort((left, right) =>
|
||||
selectorKey(left).localeCompare(selectorKey(right), "en"),
|
||||
);
|
||||
}
|
||||
|
||||
function selectorKey(selector) {
|
||||
return `${selector.modulePath}\0${selector.exportName}`;
|
||||
}
|
||||
|
||||
async function analyze(path) {
|
||||
const result = await analyzeJavaScriptApplication({
|
||||
input_path: path,
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
return result.value;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { constants } from "node:buffer";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { canonicalDigest } from "../../../dist/domain/comparisonSemantics.js";
|
||||
import {
|
||||
createEvidence,
|
||||
parseEvidence,
|
||||
} from "../../../dist/domain/evidence.js";
|
||||
|
||||
// This opt-in check crosses the actual engine string limit; keep it outside
|
||||
// routine source tests. Build first, then run this script with the local Node.
|
||||
const leaf = "x".repeat(1024 * 1024);
|
||||
const encodedLeaf = JSON.stringify(leaf);
|
||||
const count = Math.ceil(constants.MAX_STRING_LENGTH / encodedLeaf.length) + 1;
|
||||
const value = { payload: Array.from({ length: count }, () => leaf) };
|
||||
const expected = createHash("sha256");
|
||||
expected.update('{"payload":[');
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
if (index > 0) expected.update(",");
|
||||
expected.update(encodedLeaf);
|
||||
}
|
||||
expected.update("]}");
|
||||
assert.equal(canonicalDigest(value), expected.digest("hex"));
|
||||
|
||||
const evidence = createEvidence(
|
||||
undefined,
|
||||
{ id: "large-json-fixture", name: "Large JSON fixture", version: "1" },
|
||||
{ operation: "inspect_fixture", parameters: {}, result: value },
|
||||
);
|
||||
assert.equal(parseEvidence(evidence).evidence_id, evidence.evidence_id);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
verified: true,
|
||||
serialized_value_bytes: count * encodedLeaf.length + count - 1 + 14,
|
||||
engine_string_limit: constants.MAX_STRING_LENGTH,
|
||||
evidence_id: evidence.evidence_id,
|
||||
})}\n`,
|
||||
);
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { parseEvidence } from "../../../dist/domain/evidence.js";
|
||||
import { javascriptRecoveryResultSchema } from "../../../dist/domain/javascriptRecovery.js";
|
||||
import { buildRecoveryFixtures } from "../../fixtures/javascript-recovery/build.mjs";
|
||||
import { mcpTextValue } from "../../lib/mcp-verifier-results.mjs";
|
||||
|
||||
const OFFICIAL_BINARY_SHA256 =
|
||||
"e8c7ca052974604197389f6ace7464e4dd24dce64e2730971521bd6606ec9c9e";
|
||||
if (!process.env.REA_WAKARU_COMMAND)
|
||||
throw new Error(
|
||||
"Provide REA_WAKARU_COMMAND for the official Wakaru 1.13.0 Linux x64 binary",
|
||||
);
|
||||
const entrypoint =
|
||||
process.argv[2] ?? fileURLToPath(new URL("../../rea.mjs", import.meta.url));
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-real-recovery-"));
|
||||
const environment = { ...process.env, REA_LOG_LEVEL: "silent" };
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({ name: "real-recovery", version: "1" });
|
||||
const run = promisify(execFile);
|
||||
const cli = async (...args) => {
|
||||
const { stdout } = await run(
|
||||
process.execPath,
|
||||
[entrypoint, ...args, "--json"],
|
||||
{ env: environment, timeout: 150000, maxBuffer: 32 * 1024 * 1024 },
|
||||
);
|
||||
return parseEvidence(JSON.parse(stdout));
|
||||
};
|
||||
const call = async (name, args) => {
|
||||
const result = await client.callTool({ name, arguments: args }, undefined, {
|
||||
timeout: 150000,
|
||||
});
|
||||
assert.notEqual(result.isError, true, mcpTextValue(result));
|
||||
return parseEvidence(JSON.parse(mcpTextValue(result)).evidence);
|
||||
};
|
||||
const fingerprint = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
const counts = [];
|
||||
try {
|
||||
const fixtures = await buildRecoveryFixtures(
|
||||
root,
|
||||
process.env.REA_JAVASCRIPT_FIXTURE_TOOLS,
|
||||
);
|
||||
await client.connect(transport);
|
||||
for (const fixture of fixtures.files) {
|
||||
for (const surface of ["cli", "stdio_mcp"]) {
|
||||
const output = join(root, `${fixture.name}-${surface}`);
|
||||
const flags =
|
||||
fixture.options === undefined
|
||||
? []
|
||||
: [
|
||||
"--extraction-mode",
|
||||
fixture.options.extraction_mode,
|
||||
"--rewrite-level",
|
||||
fixture.options.rewrite_level,
|
||||
];
|
||||
const evidence =
|
||||
surface === "cli"
|
||||
? await cli(
|
||||
"recover-javascript-sources",
|
||||
fixture.path,
|
||||
output,
|
||||
...flags,
|
||||
)
|
||||
: await call("recover_javascript_sources", {
|
||||
path: fixture.path,
|
||||
output_directory: output,
|
||||
...fixture.options,
|
||||
});
|
||||
const result = javascriptRecoveryResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
await assertRecoveredArtifacts(evidence, result, fixture);
|
||||
const analysis =
|
||||
surface === "cli"
|
||||
? await cli(
|
||||
"analyze-javascript-application",
|
||||
result.analysis_input.input_path,
|
||||
)
|
||||
: await call("analyze_javascript_application", result.analysis_input);
|
||||
assert.equal(analysis.normalized_result.statistics.parse_failures, 0);
|
||||
assert.equal(
|
||||
analysis.normalized_result.statistics.parsed_javascript_files,
|
||||
result.modules.length,
|
||||
);
|
||||
const recoveredEntry =
|
||||
result.modules.find(
|
||||
(module) => module.reported_filename === "entry.js",
|
||||
) ?? result.modules[0];
|
||||
assert.ok(recoveredEntry);
|
||||
const oracle =
|
||||
'await import((await import("node:url")).pathToFileURL(process.argv[1])); const f=globalThis.recoveryFixture; console.log(JSON.stringify([f.greet(null),f.greet("REA"),f.total([1,2,3])]));';
|
||||
for (const selected of fixture.options?.extraction_mode === "inspection"
|
||||
? []
|
||||
: [fixture.path, recoveredEntry.artifact.path]) {
|
||||
const execution = await run(
|
||||
process.execPath,
|
||||
["--input-type=module", "-e", oracle, selected],
|
||||
{ env: environment, timeout: 10000 },
|
||||
);
|
||||
assert.deepEqual(JSON.parse(execution.stdout), [
|
||||
"Hello world",
|
||||
"Hello REA",
|
||||
6,
|
||||
]);
|
||||
}
|
||||
counts.push({
|
||||
fixture: fixture.name,
|
||||
surface,
|
||||
modules: result.modules.length,
|
||||
maps: result.modules.filter((module) => module.source_map !== null)
|
||||
.length,
|
||||
finite_fixture_oracle:
|
||||
fixture.options?.extraction_mode !== "inspection",
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
engine: "wakaru 1.13.0",
|
||||
toolchains: fixtures.toolchains,
|
||||
results: counts,
|
||||
verified: true,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertRecoveredArtifacts(evidence, result, fixture) {
|
||||
assert.deepEqual(result.options, {
|
||||
extraction_mode: "structural",
|
||||
rewrite_level: "standard",
|
||||
...fixture.options,
|
||||
});
|
||||
if (fixture.options?.extraction_mode === "inspection")
|
||||
assert.equal(result.reported_safety, "inspection-only");
|
||||
assert.equal(result.engine.executable.sha256, OFFICIAL_BINARY_SHA256);
|
||||
assert.equal(result.engine.executed_source_revision, null);
|
||||
assert.equal(evidence.confidence, "derived");
|
||||
assert.equal(result.runtime_equivalence, "unknown");
|
||||
assert.equal(result.status, "complete");
|
||||
assert.equal(result.reported_format, fixture.expectedFormat);
|
||||
assert.equal(result.modules.length, fixture.expectedModules);
|
||||
assert.equal(result.source.sha256, fingerprint(await readFile(fixture.path)));
|
||||
for (const file of [
|
||||
result.source.published_copy,
|
||||
result.report,
|
||||
result.provenance,
|
||||
result.manifest,
|
||||
...result.modules.flatMap((module) => [
|
||||
module.artifact,
|
||||
...(module.source_map ? [module.source_map] : []),
|
||||
]),
|
||||
]) {
|
||||
const bytes = await readFile(file.path);
|
||||
assert.equal(bytes.length, file.bytes);
|
||||
assert.equal(fingerprint(bytes), file.sha256);
|
||||
}
|
||||
for (const module of result.modules) {
|
||||
assert.equal(module.provenance.original_sha256, result.source.sha256);
|
||||
assert.equal(module.provenance.reported_input, result.source.snapshot_path);
|
||||
for (const range of module.provenance.byte_ranges)
|
||||
assert.ok(range.start <= range.end && range.end <= result.source.bytes);
|
||||
if (module.source_map) {
|
||||
const map = JSON.parse(await readFile(module.source_map.path, "utf8"));
|
||||
for (const source of map.sources)
|
||||
assert.equal(
|
||||
resolve(dirname(module.source_map.path), source),
|
||||
result.source.published_copy.path,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile, realpath } from "node:fs/promises";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
import { analyzeJavaScriptApplication } from "../../../dist/application/javascript/JavaScriptApplicationService.js";
|
||||
import { reconcileJavaScriptRuntimeEvidence } from "../../../dist/application/javascript/JavaScriptRuntimeReconciliationService.js";
|
||||
import {
|
||||
completeVerifierRun,
|
||||
createVerifierRun,
|
||||
} from "../../lib/verifier-run.mjs";
|
||||
|
||||
const verifierRun = createVerifierRun();
|
||||
|
||||
const options = parseArgs({
|
||||
options: {
|
||||
application: { type: "string" },
|
||||
cache: { type: "string", multiple: true, default: [] },
|
||||
assets: { type: "string", multiple: true, default: [] },
|
||||
"runtime-evidence": { type: "string", multiple: true, default: [] },
|
||||
mapping: { type: "string", multiple: true, default: [] },
|
||||
},
|
||||
strict: true,
|
||||
allowPositionals: false,
|
||||
}).values;
|
||||
|
||||
if (options.application === undefined)
|
||||
throw new Error("--application must name an operator-provided app/ASAR path");
|
||||
if (options["runtime-evidence"].length === 0)
|
||||
throw new Error(
|
||||
"At least one --runtime-evidence path from inspect_web_page or inspect_electron_page is required",
|
||||
);
|
||||
|
||||
const requestedLayers = [
|
||||
{ role: "application", path: options.application },
|
||||
...options.cache.map((path) => ({ role: "cache", path })),
|
||||
...options.assets.map((path) => ({ role: "assets", path })),
|
||||
];
|
||||
const canonicalLayers = await Promise.all(
|
||||
requestedLayers.map(async (layer) => ({
|
||||
...layer,
|
||||
path: await realpath(layer.path),
|
||||
})),
|
||||
);
|
||||
const staticLayers = [];
|
||||
for (const layer of canonicalLayers) {
|
||||
const analyzed = await analyzeJavaScriptApplication({
|
||||
input_path: layer.path,
|
||||
format: "auto",
|
||||
});
|
||||
if (!analyzed.ok) throw analyzed.error;
|
||||
staticLayers.push({
|
||||
role: layer.role,
|
||||
analysis: analyzed.value,
|
||||
runtime_mappings: [],
|
||||
});
|
||||
}
|
||||
for (const encoded of options.mapping) {
|
||||
const mapping = parseMapping(encoded, staticLayers.length);
|
||||
staticLayers[mapping.layer].runtime_mappings.push(mapping.value);
|
||||
}
|
||||
const runtimeObservations = (
|
||||
await Promise.all(options["runtime-evidence"].map(readEvidenceFile))
|
||||
).flat();
|
||||
const reconciled = reconcileJavaScriptRuntimeEvidence({
|
||||
static_layers: staticLayers,
|
||||
runtime_observations: runtimeObservations,
|
||||
});
|
||||
if (!reconciled.ok) throw reconciled.error;
|
||||
const result = reconciled.value.normalized_result;
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
verifier_run: await completeVerifierRun(verifierRun),
|
||||
reconciliation_id: result.reconciliation_id,
|
||||
evidence_id: reconciled.value.evidence_id,
|
||||
graph_id: result.graph.graph_id,
|
||||
layers: result.static_layers.map((layer) => ({
|
||||
role: layer.role,
|
||||
evidence_id: layer.evidence_id,
|
||||
graph_id: layer.graph_id,
|
||||
root_artifact_sha256: layer.root_artifact_sha256,
|
||||
input_path: layer.input_path,
|
||||
})),
|
||||
runtime_captures: result.runtime_captures.map((capture) => ({
|
||||
evidence_id: capture.evidence_id,
|
||||
capture_sha256: capture.capture_sha256,
|
||||
target_location: capture.target_location,
|
||||
})),
|
||||
summary: result.summary,
|
||||
coverage: result.coverage,
|
||||
unmatched_reasons: counted(
|
||||
result.reconciliations
|
||||
.filter(({ status }) => status !== "matched")
|
||||
.map(({ reason }) => reason),
|
||||
),
|
||||
static_load_states: counted(
|
||||
result.static_load_states.map(({ status }) => status),
|
||||
),
|
||||
verified: true,
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
async function readEvidenceFile(path) {
|
||||
const bytes = await readFile(await realpath(path));
|
||||
if (bytes.byteLength > 64 * 1024 * 1024)
|
||||
throw new Error(`Runtime Evidence exceeds 64 MiB: ${path}`);
|
||||
const value = JSON.parse(bytes.toString("utf8"));
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
function parseMapping(encoded, layerCount) {
|
||||
const parsed = JSON.parse(encoded);
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
!Number.isInteger(parsed.layer) ||
|
||||
parsed.layer < 0 ||
|
||||
parsed.layer >= layerCount ||
|
||||
(parsed.kind !== "file-root" && parsed.kind !== "url-prefix")
|
||||
)
|
||||
throw new Error(
|
||||
"--mapping must be JSON with a valid layer index and file-root or url-prefix kind",
|
||||
);
|
||||
const { layer, ...value } = parsed;
|
||||
return { layer, value };
|
||||
}
|
||||
|
||||
function counted(values) {
|
||||
return Object.fromEntries(
|
||||
[...new Set(values)]
|
||||
.sort()
|
||||
.map((value) => [
|
||||
value,
|
||||
values.filter((candidate) => candidate === value).length,
|
||||
]),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user