al-sem 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +361 -0
- package/package.json +64 -0
- package/scripts/d40-diff.ts +44 -0
- package/scripts/fetch-native-parser.ts +179 -0
- package/scripts/precision-sample.ts +99 -0
- package/scripts/precision-study.ts +42 -0
- package/scripts/precision-tabulate.ts +52 -0
- package/src/cli/baseline.ts +31 -0
- package/src/cli/diff.ts +199 -0
- package/src/cli/events-chains.ts +56 -0
- package/src/cli/events-fanout.ts +87 -0
- package/src/cli/exit-code.ts +30 -0
- package/src/cli/fingerprint-indexes.ts +130 -0
- package/src/cli/fingerprint-query.ts +543 -0
- package/src/cli/fingerprint-witness.ts +493 -0
- package/src/cli/fingerprint.ts +292 -0
- package/src/cli/format-compact-json.ts +45 -0
- package/src/cli/format-events.ts +77 -0
- package/src/cli/format-fingerprint.ts +295 -0
- package/src/cli/format-html.ts +503 -0
- package/src/cli/format-json.ts +13 -0
- package/src/cli/format-policy.ts +95 -0
- package/src/cli/format-sarif.ts +186 -0
- package/src/cli/format-terminal.ts +153 -0
- package/src/cli/index.ts +566 -0
- package/src/cli/policy.ts +204 -0
- package/src/config/roots-config.ts +302 -0
- package/src/deps/cache-versions.ts +74 -0
- package/src/deps/canonical-json.ts +27 -0
- package/src/deps/dependency-artifact.ts +144 -0
- package/src/deps/dependency-cache.ts +262 -0
- package/src/deps/dependency-dag.ts +128 -0
- package/src/deps/dependency-package-discovery.ts +85 -0
- package/src/deps/dependency-pipeline.ts +483 -0
- package/src/deps/dependency-projection.ts +211 -0
- package/src/deps/dependency-resolver.ts +154 -0
- package/src/deps/workspace-dependencies.ts +114 -0
- package/src/detectors/capability-query.ts +145 -0
- package/src/detectors/confidence.ts +52 -0
- package/src/detectors/d1-db-op-in-loop.ts +457 -0
- package/src/detectors/d10-self-modifying-loop.ts +114 -0
- package/src/detectors/d11-modify-without-get.ts +129 -0
- package/src/detectors/d12-dead-integration-event.ts +81 -0
- package/src/detectors/d13-cross-app-internal-call.ts +105 -0
- package/src/detectors/d14-dead-routine.ts +151 -0
- package/src/detectors/d16-obsolete-routine-call.ts +94 -0
- package/src/detectors/d17-min-version-drift.ts +157 -0
- package/src/detectors/d18-constant-filter-in-loop.ts +151 -0
- package/src/detectors/d19-unused-parameter.ts +116 -0
- package/src/detectors/d2-event-fanout-in-loop.ts +240 -0
- package/src/detectors/d20-unreachable-after-exit.ts +92 -0
- package/src/detectors/d21-read-without-load.ts +128 -0
- package/src/detectors/d22-flowfield-without-calcfields.ts +168 -0
- package/src/detectors/d29-subscriber-modify-on-event-record.ts +163 -0
- package/src/detectors/d3-load-state.ts +72 -0
- package/src/detectors/d3-missing-setloadfields.ts +234 -0
- package/src/detectors/d32-constant-boolean-parameter.ts +185 -0
- package/src/detectors/d33-unfiltered-bulk-write.ts +173 -0
- package/src/detectors/d34-commit-in-loop.ts +206 -0
- package/src/detectors/d35-commit-in-event-subscriber.ts +138 -0
- package/src/detectors/d36-late-setloadfields.ts +162 -0
- package/src/detectors/d37-validate-without-persist.ts +271 -0
- package/src/detectors/d38-subscriber-to-obsolete-event.ts +140 -0
- package/src/detectors/d39-record-left-dirty-across-chain.ts +165 -0
- package/src/detectors/d4-repeated-lookup-in-loop.ts +128 -0
- package/src/detectors/d40-transitive-load-missing.ts +217 -0
- package/src/detectors/d41-transitive-filter-loss.ts +200 -0
- package/src/detectors/d42-cross-call-wrong-setloadfields.ts +243 -0
- package/src/detectors/d43-event-ishandled-skip.ts +257 -0
- package/src/detectors/d44-event-multi-subscriber-overlap.ts +223 -0
- package/src/detectors/d45-event-transitive-table-exposure.ts +159 -0
- package/src/detectors/d5-set-based-opportunity.ts +162 -0
- package/src/detectors/d7-recursive-event-expansion.ts +151 -0
- package/src/detectors/d8-commit-in-transaction.ts +132 -0
- package/src/detectors/d9-transaction-span-summary.ts +107 -0
- package/src/detectors/detector-context.ts +121 -0
- package/src/detectors/finding-grouping.ts +61 -0
- package/src/detectors/path-merge.ts +174 -0
- package/src/detectors/registry.ts +176 -0
- package/src/detectors/table-display.ts +42 -0
- package/src/diff/diff-abi.ts +195 -0
- package/src/diff/diff-capabilities.ts +179 -0
- package/src/diff/diff-engine.ts +146 -0
- package/src/diff/diff-events.ts +323 -0
- package/src/diff/diff-identity.ts +73 -0
- package/src/diff/diff-indexes.ts +199 -0
- package/src/diff/diff-permissions.ts +260 -0
- package/src/diff/diff-policy.ts +101 -0
- package/src/diff/diff-preflight.ts +66 -0
- package/src/diff/diff-renames.ts +104 -0
- package/src/diff/diff-schema.ts +232 -0
- package/src/diff/format-diff.ts +148 -0
- package/src/engine/attribute-parser.ts +50 -0
- package/src/engine/capability-cone.ts +531 -0
- package/src/engine/combined-graph.ts +357 -0
- package/src/engine/control-flow-walker.ts +1317 -0
- package/src/engine/dispatch-sites.ts +199 -0
- package/src/engine/effect-lattice.ts +81 -0
- package/src/engine/entry-points.ts +57 -0
- package/src/engine/event-flow.ts +524 -0
- package/src/engine/event-relay.ts +92 -0
- package/src/engine/op-classification.ts +92 -0
- package/src/engine/path-walker.ts +189 -0
- package/src/engine/reverse-call-graph.ts +23 -0
- package/src/engine/root-classifier-overlay.ts +194 -0
- package/src/engine/root-classifier.ts +135 -0
- package/src/engine/scc.ts +110 -0
- package/src/engine/source-anchor.ts +25 -0
- package/src/engine/summary-context.ts +104 -0
- package/src/engine/summary-engine.ts +296 -0
- package/src/engine/summary-runner.ts +560 -0
- package/src/engine/transaction-spans.ts +112 -0
- package/src/engine/uncertainty-util.ts +54 -0
- package/src/hash.ts +31 -0
- package/src/index/attribute-from-node.ts +141 -0
- package/src/index/callee-from-node.ts +181 -0
- package/src/index/capability/background.ts +90 -0
- package/src/index/capability/commit.ts +44 -0
- package/src/index/capability/dispatch.ts +164 -0
- package/src/index/capability/events.ts +65 -0
- package/src/index/capability/extractor.ts +124 -0
- package/src/index/capability/file-blob.ts +137 -0
- package/src/index/capability/http.ts +159 -0
- package/src/index/capability/hyperlink.ts +60 -0
- package/src/index/capability/isolated-storage.ts +179 -0
- package/src/index/capability/table.ts +113 -0
- package/src/index/capability/telemetry.ts +84 -0
- package/src/index/capability/ui.ts +55 -0
- package/src/index/capability/value-source.ts +202 -0
- package/src/index/expression-from-node.ts +117 -0
- package/src/index/indexer.ts +102 -0
- package/src/index/intraprocedural-body.ts +1467 -0
- package/src/index/intraprocedural-ops.ts +253 -0
- package/src/index/intraprocedural-refs.ts +188 -0
- package/src/index/object-indexer.ts +279 -0
- package/src/index/routine-indexer.ts +282 -0
- package/src/index/routine-signature.ts +46 -0
- package/src/index/variable-indexer.ts +134 -0
- package/src/index/variable-initializer-extractor.ts +155 -0
- package/src/index/variable-type-normalizer.ts +83 -0
- package/src/index.ts +267 -0
- package/src/mcp/server.ts +72 -0
- package/src/mcp/session.ts +49 -0
- package/src/mcp/tools/explain-path.ts +75 -0
- package/src/mcp/tools/get-analysis-health.ts +62 -0
- package/src/mcp/tools/get-finding.ts +47 -0
- package/src/mcp/tools/get-routine-summary.ts +126 -0
- package/src/mcp/tools/list-findings.ts +85 -0
- package/src/mcp/tools/list-hotspots.ts +78 -0
- package/src/mcp/tools/list-rollups.ts +103 -0
- package/src/mcp/tools/validators.ts +25 -0
- package/src/model/attributes.ts +120 -0
- package/src/model/callee.ts +45 -0
- package/src/model/capability.ts +187 -0
- package/src/model/coverage.ts +85 -0
- package/src/model/entities.ts +628 -0
- package/src/model/expression.ts +98 -0
- package/src/model/finding.ts +110 -0
- package/src/model/graph-edge.ts +93 -0
- package/src/model/graph.ts +62 -0
- package/src/model/identity.ts +81 -0
- package/src/model/ids.ts +90 -0
- package/src/model/index.ts +13 -0
- package/src/model/model.ts +51 -0
- package/src/model/permission.ts +76 -0
- package/src/model/root-classification.ts +116 -0
- package/src/model/stable-identity.ts +102 -0
- package/src/model/summary.ts +96 -0
- package/src/parser/ast.ts +82 -0
- package/src/parser/native/ffi.ts +145 -0
- package/src/parser/native/parse-index-pool.ts +148 -0
- package/src/parser/native/parse-index-worker.ts +94 -0
- package/src/parser/native/wrapper.ts +353 -0
- package/src/parser/parser-init.ts +43 -0
- package/src/perf/profiler.ts +66 -0
- package/src/policy/policy-default.yaml +83 -0
- package/src/policy/policy-engine.ts +339 -0
- package/src/policy/policy-loader.ts +257 -0
- package/src/policy/policy-schema.json +379 -0
- package/src/policy/policy-types.ts +81 -0
- package/src/policy/predicate-compiler.ts +151 -0
- package/src/policy/predicate-evaluator.ts +267 -0
- package/src/policy/predicate-fields.ts +439 -0
- package/src/projection/actionable-anchor.ts +48 -0
- package/src/projection/finding-filters.ts +44 -0
- package/src/projection/finding-fingerprint.ts +54 -0
- package/src/projection/finding-groups.ts +41 -0
- package/src/projection/finding-summary.ts +110 -0
- package/src/projection/rollup-findings.ts +105 -0
- package/src/providers/discover.ts +88 -0
- package/src/providers/external.ts +46 -0
- package/src/providers/types.ts +36 -0
- package/src/providers/workspace.ts +117 -0
- package/src/resolve/call-resolver.ts +117 -0
- package/src/resolve/coverage.ts +61 -0
- package/src/resolve/event-graph.ts +166 -0
- package/src/resolve/implicit-edges.ts +53 -0
- package/src/resolve/record-types.ts +36 -0
- package/src/resolve/resolver.ts +23 -0
- package/src/resolve/semantic-graph.ts +29 -0
- package/src/resolve/symbol-table.ts +69 -0
- package/src/snapshot/app-snapshot.ts +74 -0
- package/src/snapshot/compose.ts +100 -0
- package/src/snapshot/derive/callsite-evidence.ts +76 -0
- package/src/snapshot/derive/capability-facts.ts +70 -0
- package/src/snapshot/derive/contracts.ts +131 -0
- package/src/snapshot/derive/coverage.ts +35 -0
- package/src/snapshot/derive/event-declarations.ts +140 -0
- package/src/snapshot/derive/identity-table.ts +58 -0
- package/src/snapshot/derive/inputs.ts +91 -0
- package/src/snapshot/derive/operation-evidence.ts +70 -0
- package/src/snapshot/derive/permissions.ts +186 -0
- package/src/snapshot/derive/root-classifications.ts +56 -0
- package/src/snapshot/derive/schema.ts +130 -0
- package/src/snapshot/derive/typed-edges.ts +60 -0
- package/src/snapshot/derive/workspace-fingerprint.ts +19 -0
- package/src/snapshot/deserialize.ts +40 -0
- package/src/snapshot/serialize-cbor-gz.ts +12 -0
- package/src/snapshot/serialize-cbor.ts +19 -0
- package/src/snapshot/serialize-json.ts +22 -0
- package/src/snapshot/shard.ts +134 -0
- package/src/snapshot/types.ts +181 -0
- package/src/symbols/app-manifest.ts +96 -0
- package/src/symbols/app-package-zip.ts +50 -0
- package/src/symbols/embedded-source-reader.ts +41 -0
- package/src/symbols/package-hash.ts +81 -0
- package/src/symbols/symbol-reader.ts +101 -0
- package/src/symbols/symbol-reference-parser.ts +378 -0
- package/src/symbols/symbol-reference-reader.ts +27 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, join, relative } from "node:path";
|
|
4
|
+
import type { SemanticModel } from "../../model/model.ts";
|
|
5
|
+
import type { SnapshotInput } from "../types.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Walk the workspace + .alpackages directory, fingerprint each input file
|
|
9
|
+
* that contributes to snapshot identity:
|
|
10
|
+
* - app.json → kind:"app-json"
|
|
11
|
+
* - .alpackages/*.app → kind:"dep-package"
|
|
12
|
+
* - roots.config.json (if loaded) → kind:"roots-config"
|
|
13
|
+
* - al-sem.coverage.yaml (if present) → kind:"policy"
|
|
14
|
+
*
|
|
15
|
+
* The `roots-config` entry is sourced from `model.identity.rootsConfig`
|
|
16
|
+
* rather than re-read from disk. `analyzeWorkspace` is the sole loader and
|
|
17
|
+
* sets that field only when the config was actually consulted — so
|
|
18
|
+
* `analyzeWorkspace({ noRootsConfig: true })` keeps the file out of the
|
|
19
|
+
* fingerprint even when it exists on disk. This is what lets
|
|
20
|
+
* `--no-roots-config` produce a different `workspaceFingerprint` from a
|
|
21
|
+
* baseline run, so diff tools can tell the two states apart.
|
|
22
|
+
*
|
|
23
|
+
* Paths relative to workspaceDir, forward-slash-normalised. Output sorted by
|
|
24
|
+
* (kind, path).
|
|
25
|
+
*/
|
|
26
|
+
export function deriveInputs(model: SemanticModel, workspaceDir: string): SnapshotInput[] {
|
|
27
|
+
const out: SnapshotInput[] = [];
|
|
28
|
+
|
|
29
|
+
// Standalone .app subject (snapshot-from-.app): the .app file IS the sole input,
|
|
30
|
+
// fingerprinted by its raw bytes so distinct .apps get distinct workspaceFingerprints.
|
|
31
|
+
if (
|
|
32
|
+
workspaceDir.toLowerCase().endsWith(".app") &&
|
|
33
|
+
existsSync(workspaceDir) &&
|
|
34
|
+
statSync(workspaceDir).isFile()
|
|
35
|
+
) {
|
|
36
|
+
return [
|
|
37
|
+
{ kind: "app-package", path: basename(workspaceDir), contentHash: hashFile(workspaceDir) },
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const appJson = join(workspaceDir, "app.json");
|
|
42
|
+
if (existsSync(appJson)) {
|
|
43
|
+
out.push({
|
|
44
|
+
kind: "app-json",
|
|
45
|
+
path: rel(workspaceDir, appJson),
|
|
46
|
+
contentHash: hashFile(appJson),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const alpackages = join(workspaceDir, ".alpackages");
|
|
51
|
+
if (existsSync(alpackages) && statSync(alpackages).isDirectory()) {
|
|
52
|
+
for (const name of readdirSync(alpackages)) {
|
|
53
|
+
if (!name.endsWith(".app")) continue;
|
|
54
|
+
const p = join(alpackages, name);
|
|
55
|
+
out.push({ kind: "dep-package", path: rel(workspaceDir, p), contentHash: hashFile(p) });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// roots.config.json: read identity rather than re-hash from disk. This
|
|
60
|
+
// lets `analyzeWorkspace({ noRootsConfig: true })` keep the file out of
|
|
61
|
+
// the fingerprint even when the file exists on disk. `analyzeWorkspace`
|
|
62
|
+
// sets `model.identity.rootsConfig` only when the config was actually
|
|
63
|
+
// loaded.
|
|
64
|
+
if (model.identity.rootsConfig !== undefined) {
|
|
65
|
+
out.push({
|
|
66
|
+
kind: "roots-config",
|
|
67
|
+
path: rel(workspaceDir, model.identity.rootsConfig.path),
|
|
68
|
+
contentHash: model.identity.rootsConfig.contentHash,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const coveragePolicy = join(workspaceDir, "al-sem.coverage.yaml");
|
|
73
|
+
if (existsSync(coveragePolicy)) {
|
|
74
|
+
out.push({
|
|
75
|
+
kind: "policy",
|
|
76
|
+
path: rel(workspaceDir, coveragePolicy),
|
|
77
|
+
contentHash: hashFile(coveragePolicy),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
out.sort((a, b) => `${a.kind}|${a.path}`.localeCompare(`${b.kind}|${b.path}`));
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function hashFile(p: string): string {
|
|
86
|
+
return createHash("sha256").update(readFileSync(p)).digest("hex");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function rel(from: string, p: string): string {
|
|
90
|
+
return relative(from, p).split("\\").join("/");
|
|
91
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { SemanticModel } from "../../model/model.ts";
|
|
2
|
+
import { createIdentityIndex } from "../../model/stable-identity.ts";
|
|
3
|
+
import type { StableRoutineId } from "../../model/stable-identity.ts";
|
|
4
|
+
import type { OperationEvidence, SnapshotIdentityTable } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Emit OperationEvidence for every OperationSite + RecordOperation that
|
|
8
|
+
* appears as the witnessOperationId of a CapabilityFact (direct or inherited).
|
|
9
|
+
*
|
|
10
|
+
* Sort by operationId.
|
|
11
|
+
*/
|
|
12
|
+
export function deriveOperationEvidence(
|
|
13
|
+
model: SemanticModel,
|
|
14
|
+
_idx: SnapshotIdentityTable,
|
|
15
|
+
): OperationEvidence[] {
|
|
16
|
+
const idCvt = createIdentityIndex();
|
|
17
|
+
const referenced = new Set<string>();
|
|
18
|
+
for (const r of model.routines ?? []) {
|
|
19
|
+
const summary = r.summary;
|
|
20
|
+
if (summary === undefined) continue;
|
|
21
|
+
for (const f of [
|
|
22
|
+
...(summary.capabilityFactsDirect ?? []),
|
|
23
|
+
...(summary.capabilityFactsInherited ?? []),
|
|
24
|
+
]) {
|
|
25
|
+
if (f.witnessOperationId !== undefined) {
|
|
26
|
+
referenced.add(String(f.witnessOperationId));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const out: OperationEvidence[] = [];
|
|
32
|
+
for (const r of model.routines ?? []) {
|
|
33
|
+
const stableObject = idCvt.toStableObjectId(r.objectId);
|
|
34
|
+
const stableRoutine = idCvt.toStableRoutineIdFromParts(
|
|
35
|
+
stableObject,
|
|
36
|
+
r.canonical?.normalizedSignatureHash ?? "",
|
|
37
|
+
) as StableRoutineId;
|
|
38
|
+
|
|
39
|
+
for (const op of r.features?.operationSites ?? []) {
|
|
40
|
+
if (!referenced.has(String(op.id))) continue;
|
|
41
|
+
const a = op.sourceAnchor;
|
|
42
|
+
out.push({
|
|
43
|
+
operationId: op.id,
|
|
44
|
+
routine: stableRoutine,
|
|
45
|
+
sourceFile: a.sourceUnitId,
|
|
46
|
+
startLine: a.range.startLine,
|
|
47
|
+
startColumn: a.range.startColumn,
|
|
48
|
+
endLine: a.range.endLine,
|
|
49
|
+
endColumn: a.range.endColumn,
|
|
50
|
+
displayText: op.kind,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
for (const ro of r.features?.recordOperations ?? []) {
|
|
54
|
+
if (!referenced.has(String(ro.id))) continue;
|
|
55
|
+
const a = ro.sourceAnchor;
|
|
56
|
+
out.push({
|
|
57
|
+
operationId: ro.id,
|
|
58
|
+
routine: stableRoutine,
|
|
59
|
+
sourceFile: a.sourceUnitId,
|
|
60
|
+
startLine: a.range.startLine,
|
|
61
|
+
startColumn: a.range.startColumn,
|
|
62
|
+
endLine: a.range.endLine,
|
|
63
|
+
endColumn: a.range.endColumn,
|
|
64
|
+
displayText: `${ro.recordVariableName ?? "?"}.${ro.op}`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
out.sort((a, b) => String(a.operationId).localeCompare(String(b.operationId)));
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// src/snapshot/derive/permissions.ts
|
|
2
|
+
//
|
|
3
|
+
// Two halves merged into one PermissionFact[]:
|
|
4
|
+
//
|
|
5
|
+
// (a) DeclaredPermissionFact[] from model.permissionSets when present.
|
|
6
|
+
// Phase 0c ships best-effort: the model does not expose permissionSets
|
|
7
|
+
// yet. Phase 4 wires PermissionSet projection.
|
|
8
|
+
//
|
|
9
|
+
// (b) RequiredPermissionFact[] derived per spec §3.8:
|
|
10
|
+
// read|insert|modify|delete on table T → R|I|M|D on TableData T
|
|
11
|
+
// execute on codeunit|page|report O → X on O
|
|
12
|
+
// Only facts whose resourceId is resolved (not undefined or null) are emitted —
|
|
13
|
+
// unresolved capability facts carry no stable target and cannot produce
|
|
14
|
+
// a meaningful permission entry.
|
|
15
|
+
//
|
|
16
|
+
// Each required fact carries the coverage status of its source routine's
|
|
17
|
+
// inherited cone (G6 enforcement). Output sorted canonically.
|
|
18
|
+
|
|
19
|
+
import type { CapabilityOp } from "../../model/capability.ts";
|
|
20
|
+
import type { SemanticModel } from "../../model/model.ts";
|
|
21
|
+
import type {
|
|
22
|
+
PermissionFact,
|
|
23
|
+
PermissionRight,
|
|
24
|
+
RequiredPermissionFact,
|
|
25
|
+
} from "../../model/permission.ts";
|
|
26
|
+
import { createIdentityIndex } from "../../model/stable-identity.ts";
|
|
27
|
+
import type {
|
|
28
|
+
StableObjectId,
|
|
29
|
+
StableRoutineId,
|
|
30
|
+
StableTableId,
|
|
31
|
+
} from "../../model/stable-identity.ts";
|
|
32
|
+
import type { SnapshotIdentityTable } from "../types.ts";
|
|
33
|
+
|
|
34
|
+
const TABLE_OP_TO_RIGHT: Partial<Record<CapabilityOp, PermissionRight>> = {
|
|
35
|
+
read: "R",
|
|
36
|
+
insert: "I",
|
|
37
|
+
modify: "M",
|
|
38
|
+
delete: "D",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Derive the full PermissionFact[] for the snapshot.
|
|
43
|
+
*
|
|
44
|
+
* Two halves merged into one array:
|
|
45
|
+
*
|
|
46
|
+
* (a) DeclaredPermissionFact[] from PermissionSet objects — minimal in
|
|
47
|
+
* Phase 0c (the model does not project permissionSets yet; Phase 4
|
|
48
|
+
* enriches this branch).
|
|
49
|
+
*
|
|
50
|
+
* (b) RequiredPermissionFact[] derived per spec §3.8:
|
|
51
|
+
* read|insert|modify|delete on table T → R|I|M|D on TableData T
|
|
52
|
+
* execute on codeunit|page|report O → X on O
|
|
53
|
+
*
|
|
54
|
+
* Each required perm carries coverage status — G6 enforcement: callers
|
|
55
|
+
* see "may be incomplete" when the cone is partial. Output sorted
|
|
56
|
+
* canonically by (kind, subject/set, target, rights).
|
|
57
|
+
*
|
|
58
|
+
* `idx` is the SnapshotIdentityTable produced earlier; not consulted
|
|
59
|
+
* directly (id rewriting goes through createIdentityIndex), but accepting
|
|
60
|
+
* it keeps the deriver signature uniform with the rest of the family.
|
|
61
|
+
*/
|
|
62
|
+
export function derivePermissions(
|
|
63
|
+
model: SemanticModel,
|
|
64
|
+
_idx: SnapshotIdentityTable,
|
|
65
|
+
): PermissionFact[] {
|
|
66
|
+
const idCvt = createIdentityIndex();
|
|
67
|
+
const out: PermissionFact[] = [];
|
|
68
|
+
|
|
69
|
+
// (a) Declared — only emit if the model exposes permissionSets.
|
|
70
|
+
// Phase 4 wires PermissionSet projection; Phase 0c is best-effort.
|
|
71
|
+
const ps = (model as unknown as { permissionSets?: unknown[] }).permissionSets;
|
|
72
|
+
if (Array.isArray(ps)) {
|
|
73
|
+
for (const item of ps) {
|
|
74
|
+
const entry = item as Record<string, unknown>;
|
|
75
|
+
if (
|
|
76
|
+
typeof entry.permissionSet === "string" &&
|
|
77
|
+
typeof entry.target === "string" &&
|
|
78
|
+
typeof entry.targetKind === "string" &&
|
|
79
|
+
Array.isArray(entry.rights) &&
|
|
80
|
+
typeof entry.scope === "string"
|
|
81
|
+
) {
|
|
82
|
+
out.push({
|
|
83
|
+
kind: "declared",
|
|
84
|
+
permissionSet: entry.permissionSet as never,
|
|
85
|
+
target: entry.target as never,
|
|
86
|
+
targetKind: entry.targetKind as never,
|
|
87
|
+
rights: entry.rights as PermissionRight[],
|
|
88
|
+
scope: entry.scope as "Inherent" | "Assignable",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// (b) Required — one entry per (routine, target, right) triple.
|
|
95
|
+
for (const r of model.routines ?? []) {
|
|
96
|
+
const summary = r.summary;
|
|
97
|
+
if (summary === undefined) continue;
|
|
98
|
+
|
|
99
|
+
const stableObject = idCvt.toStableObjectId(r.objectId);
|
|
100
|
+
const stableSubject = idCvt.toStableRoutineIdFromParts(
|
|
101
|
+
stableObject,
|
|
102
|
+
r.canonical.normalizedSignatureHash,
|
|
103
|
+
) as StableRoutineId;
|
|
104
|
+
|
|
105
|
+
// Coverage: prefer inheritedStatus (whole cone), fall back to
|
|
106
|
+
// directStatus, then "unknown".
|
|
107
|
+
const coverage =
|
|
108
|
+
summary.coverage?.inheritedStatus ?? summary.coverage?.directStatus ?? "unknown";
|
|
109
|
+
|
|
110
|
+
const facts = [
|
|
111
|
+
...(summary.capabilityFactsDirect ?? []),
|
|
112
|
+
...(summary.capabilityFactsInherited ?? []),
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
// Deduplicate within this routine's contribution.
|
|
116
|
+
const seen = new Set<string>();
|
|
117
|
+
|
|
118
|
+
for (const f of facts) {
|
|
119
|
+
if (f.resourceKind === "table" && f.resourceId != null) {
|
|
120
|
+
const right = TABLE_OP_TO_RIGHT[f.op];
|
|
121
|
+
if (right === undefined) continue;
|
|
122
|
+
const stableTable = idCvt.toStableTableId(f.resourceId as string);
|
|
123
|
+
const key = `${stableSubject}|${stableTable}|${right}`;
|
|
124
|
+
if (seen.has(key)) continue;
|
|
125
|
+
seen.add(key);
|
|
126
|
+
const req: RequiredPermissionFact = {
|
|
127
|
+
kind: "required",
|
|
128
|
+
subject: stableSubject,
|
|
129
|
+
target: stableTable as StableTableId,
|
|
130
|
+
targetKind: "TableData",
|
|
131
|
+
rights: [right],
|
|
132
|
+
derivedFromCapability: {
|
|
133
|
+
op: f.op,
|
|
134
|
+
...(f.witnessCallsiteId !== undefined
|
|
135
|
+
? { witnessCallsiteId: f.witnessCallsiteId }
|
|
136
|
+
: {}),
|
|
137
|
+
},
|
|
138
|
+
coverage,
|
|
139
|
+
};
|
|
140
|
+
out.push(req);
|
|
141
|
+
} else if (
|
|
142
|
+
f.op === "execute" &&
|
|
143
|
+
f.resourceId != null &&
|
|
144
|
+
(f.resourceKind === "codeunit" || f.resourceKind === "page" || f.resourceKind === "report")
|
|
145
|
+
) {
|
|
146
|
+
const stableObj = idCvt.toStableObjectId(f.resourceId as string);
|
|
147
|
+
const key = `${stableSubject}|${stableObj}|X`;
|
|
148
|
+
if (seen.has(key)) continue;
|
|
149
|
+
seen.add(key);
|
|
150
|
+
const req: RequiredPermissionFact = {
|
|
151
|
+
kind: "required",
|
|
152
|
+
subject: stableSubject,
|
|
153
|
+
target: stableObj as StableObjectId,
|
|
154
|
+
targetKind: capitaliseObjectKind(f.resourceKind),
|
|
155
|
+
rights: ["X"],
|
|
156
|
+
derivedFromCapability: {
|
|
157
|
+
op: f.op,
|
|
158
|
+
...(f.witnessCallsiteId !== undefined
|
|
159
|
+
? { witnessCallsiteId: f.witnessCallsiteId }
|
|
160
|
+
: {}),
|
|
161
|
+
},
|
|
162
|
+
coverage,
|
|
163
|
+
};
|
|
164
|
+
out.push(req);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
out.sort((a, b) => permKey(a).localeCompare(permKey(b)));
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function capitaliseObjectKind(k: "codeunit" | "page" | "report"): "Codeunit" | "Page" | "Report" {
|
|
174
|
+
if (k === "codeunit") return "Codeunit";
|
|
175
|
+
if (k === "page") return "Page";
|
|
176
|
+
return "Report";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function permKey(p: PermissionFact): string {
|
|
180
|
+
if (p.kind === "declared") {
|
|
181
|
+
return ["D", String(p.permissionSet), String(p.target), p.targetKind, p.rights.join("")].join(
|
|
182
|
+
"|",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return ["R", String(p.subject), String(p.target), p.targetKind, p.rights.join("")].join("|");
|
|
186
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { SemanticModel } from "../../model/model.ts";
|
|
2
|
+
import { createIdentityIndex } from "../../model/stable-identity.ts";
|
|
3
|
+
import type { StableRoutineId } from "../../model/stable-identity.ts";
|
|
4
|
+
import type { RootClassificationSlot, SnapshotIdentityTable } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Project `model.rootClassifications` (Phase 1 §4.3 classifier output) into
|
|
8
|
+
* the snapshot's `RootClassificationSlot[]` shape. Only the `routineId` field
|
|
9
|
+
* is rewritten (internal `RoutineId` → `StableRoutineId`); all provenance
|
|
10
|
+
* fields (`source`, `confidence`, `sourceAnchor`, `configEntryId`,
|
|
11
|
+
* `resolutionStatus`) pass through verbatim.
|
|
12
|
+
*
|
|
13
|
+
* Routines that don't appear in `model.routines` are silently dropped — they
|
|
14
|
+
* shouldn't exist in practice (the classifier only emits for known routines),
|
|
15
|
+
* but the engine-never-throws contract is preserved either way.
|
|
16
|
+
*
|
|
17
|
+
* Output is sorted by `StableRoutineId` for determinism, matching every other
|
|
18
|
+
* deriver in `src/snapshot/derive/`.
|
|
19
|
+
*/
|
|
20
|
+
export function deriveRootClassifications(
|
|
21
|
+
model: SemanticModel,
|
|
22
|
+
_idx: SnapshotIdentityTable,
|
|
23
|
+
): RootClassificationSlot[] {
|
|
24
|
+
const idCvt = createIdentityIndex();
|
|
25
|
+
|
|
26
|
+
// Build internal RoutineId → StableRoutineId lookup once.
|
|
27
|
+
const routineToStable = new Map<string, StableRoutineId>();
|
|
28
|
+
for (const r of model.routines ?? []) {
|
|
29
|
+
const stableObject = idCvt.toStableObjectId(r.objectId);
|
|
30
|
+
const stable = idCvt.toStableRoutineIdFromParts(
|
|
31
|
+
stableObject,
|
|
32
|
+
r.canonical?.normalizedSignatureHash ?? "",
|
|
33
|
+
) as StableRoutineId;
|
|
34
|
+
routineToStable.set(String(r.id), stable);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const out: RootClassificationSlot[] = [];
|
|
38
|
+
for (const c of model.rootClassifications ?? []) {
|
|
39
|
+
const stable = routineToStable.get(String(c.routineId));
|
|
40
|
+
if (stable === undefined) continue;
|
|
41
|
+
const slot: RootClassificationSlot = {
|
|
42
|
+
routineId: stable,
|
|
43
|
+
kinds: [...c.kinds],
|
|
44
|
+
externallyReachable: c.externallyReachable,
|
|
45
|
+
source: c.source,
|
|
46
|
+
confidence: c.confidence,
|
|
47
|
+
};
|
|
48
|
+
if (c.sourceAnchor !== undefined) slot.sourceAnchor = c.sourceAnchor;
|
|
49
|
+
if (c.configEntryId !== undefined) slot.configEntryId = c.configEntryId;
|
|
50
|
+
if (c.resolutionStatus !== undefined) slot.resolutionStatus = c.resolutionStatus;
|
|
51
|
+
out.push(slot);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
out.sort((a, b) => String(a.routineId).localeCompare(String(b.routineId)));
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { Field, Key, Table } from "../../model/entities.ts";
|
|
3
|
+
import type { SemanticModel } from "../../model/model.ts";
|
|
4
|
+
import { createIdentityIndex } from "../../model/stable-identity.ts";
|
|
5
|
+
import type { SchemaFact, SnapshotIdentityTable } from "../types.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Project every workspace table + its fields + keys to SchemaFact[].
|
|
9
|
+
*
|
|
10
|
+
* Enum + enum-value projections are deferred to Phase 1+ schema work — the
|
|
11
|
+
* model does not expose enum members at L2 in the current pipeline.
|
|
12
|
+
*
|
|
13
|
+
* shapeFingerprint = SHA-256(canonical-json(shape)):
|
|
14
|
+
* Table shape: { number, name }
|
|
15
|
+
* Field shape: { dataType, fieldClass, isBlobLike }
|
|
16
|
+
* Key shape: { fields: StableFieldId[], isEnabled }
|
|
17
|
+
* where fields are the stable field ids (appGuid:Table:N#fieldNum) sorted
|
|
18
|
+
* to make the fingerprint position-independent within the field list.
|
|
19
|
+
*
|
|
20
|
+
* stableId formats (from createIdentityIndex):
|
|
21
|
+
* Table: `${appGuid}:Table:${tableNumber}`
|
|
22
|
+
* Field: `${stableTableId}#${fieldNumber}`
|
|
23
|
+
* Key: `${stableTableId}#K${keyIndex}` (index from the Key.id suffix)
|
|
24
|
+
*
|
|
25
|
+
* Output sorted by stableId for determinism.
|
|
26
|
+
*/
|
|
27
|
+
export function deriveSchema(model: SemanticModel, _idx: SnapshotIdentityTable): SchemaFact[] {
|
|
28
|
+
const idCvt = createIdentityIndex();
|
|
29
|
+
const out: SchemaFact[] = [];
|
|
30
|
+
|
|
31
|
+
for (const tbl of model.tables ?? []) {
|
|
32
|
+
const stableTable = idCvt.toStableTableId(tbl.id);
|
|
33
|
+
out.push(makeTableFact(tbl, stableTable));
|
|
34
|
+
|
|
35
|
+
for (const fld of tbl.fields) {
|
|
36
|
+
const stableField = idCvt.toStableFieldId(fld.id);
|
|
37
|
+
out.push(makeFieldFact(fld, stableField, idCvt));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const key of tbl.keys) {
|
|
41
|
+
const stableKey = stableKeyId(key.id, stableTable);
|
|
42
|
+
out.push(makeKeyFact(key, stableKey, idCvt));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
out.sort((a, b) => a.stableId.localeCompare(b.stableId));
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Per-entity fact builders
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
function makeTableFact(tbl: Table, stableId: string): SchemaFact {
|
|
55
|
+
return {
|
|
56
|
+
kind: "table",
|
|
57
|
+
stableId,
|
|
58
|
+
shapeFingerprint: sha256(canonicalJson({ number: tbl.tableNumber, name: tbl.name })),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeFieldFact(
|
|
63
|
+
fld: Field,
|
|
64
|
+
stableId: string,
|
|
65
|
+
_idCvt: ReturnType<typeof createIdentityIndex>,
|
|
66
|
+
): SchemaFact {
|
|
67
|
+
return {
|
|
68
|
+
kind: "field",
|
|
69
|
+
stableId,
|
|
70
|
+
shapeFingerprint: sha256(
|
|
71
|
+
canonicalJson({
|
|
72
|
+
dataType: fld.dataType,
|
|
73
|
+
fieldClass: fld.fieldClass,
|
|
74
|
+
isBlobLike: fld.isBlobLike,
|
|
75
|
+
}),
|
|
76
|
+
),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function makeKeyFact(
|
|
81
|
+
key: Key,
|
|
82
|
+
stableId: string,
|
|
83
|
+
idCvt: ReturnType<typeof createIdentityIndex>,
|
|
84
|
+
): SchemaFact {
|
|
85
|
+
// Convert internal FieldIds to stable ids for cross-version stability.
|
|
86
|
+
const stableFields = key.fields.map((f) => idCvt.toStableFieldId(f)).sort();
|
|
87
|
+
return {
|
|
88
|
+
kind: "key",
|
|
89
|
+
stableId,
|
|
90
|
+
shapeFingerprint: sha256(
|
|
91
|
+
canonicalJson({
|
|
92
|
+
fields: stableFields,
|
|
93
|
+
isEnabled: key.isEnabled ?? true,
|
|
94
|
+
}),
|
|
95
|
+
),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Key stable-id derivation
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Derive a stable key id from the raw KeyId.
|
|
105
|
+
* KeyId format: `${tableId}/key/${index}` (from encodeKeyId in model/ids.ts).
|
|
106
|
+
* We strip the table prefix and replace the `/key/` segment with `#K` so the
|
|
107
|
+
* result is: `${stableTableId}#K${keyIndex}`.
|
|
108
|
+
*/
|
|
109
|
+
function stableKeyId(keyId: string, stableTable: string): string {
|
|
110
|
+
const marker = "/key/";
|
|
111
|
+
const pos = keyId.lastIndexOf(marker);
|
|
112
|
+
const keyIndex = pos >= 0 ? keyId.slice(pos + marker.length) : keyId;
|
|
113
|
+
return `${stableTable}#K${keyIndex}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Helpers
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
function canonicalJson(v: unknown): string {
|
|
121
|
+
if (v === null || typeof v !== "object") return JSON.stringify(v);
|
|
122
|
+
if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
|
|
123
|
+
const o = v as Record<string, unknown>;
|
|
124
|
+
const keys = Object.keys(o).sort();
|
|
125
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`).join(",")}}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function sha256(s: string): string {
|
|
129
|
+
return createHash("sha256").update(s, "utf8").digest("hex");
|
|
130
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// src/snapshot/derive/typed-edges.ts
|
|
2
|
+
//
|
|
3
|
+
// Project model.typedEdges (populated by Phase 0b-β combined-graph) with
|
|
4
|
+
// from/to rewritten from internal RoutineId → StableRoutineId.
|
|
5
|
+
//
|
|
6
|
+
// Every edge in the discriminated union has a `from: RoutineId` field.
|
|
7
|
+
// All except `object-run-unresolved` also have a `to: RoutineId` field.
|
|
8
|
+
// Both are rewritten in-place on a shallow copy of the edge.
|
|
9
|
+
//
|
|
10
|
+
// Sort key: (kind, from, to).
|
|
11
|
+
|
|
12
|
+
import type { GraphEdge } from "../../model/graph-edge.ts";
|
|
13
|
+
import type { SemanticModel } from "../../model/model.ts";
|
|
14
|
+
import { createIdentityIndex } from "../../model/stable-identity.ts";
|
|
15
|
+
import type { SnapshotIdentityTable } from "../types.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Project model.typedEdges (populated by Phase 0b-β combined-graph) with
|
|
19
|
+
* from/to rewritten internal RoutineId → StableRoutineId.
|
|
20
|
+
*
|
|
21
|
+
* Sort by (kind, from, to).
|
|
22
|
+
*/
|
|
23
|
+
export function deriveTypedEdges(model: SemanticModel, _idx: SnapshotIdentityTable): GraphEdge[] {
|
|
24
|
+
const idCvt = createIdentityIndex();
|
|
25
|
+
|
|
26
|
+
// Build a RoutineId → StableRoutineId lookup for every routine in the model.
|
|
27
|
+
const routineToStable = new Map<string, string>();
|
|
28
|
+
for (const r of model.routines ?? []) {
|
|
29
|
+
const stableObject = idCvt.toStableObjectId(r.objectId);
|
|
30
|
+
const stable = idCvt.toStableRoutineIdFromParts(
|
|
31
|
+
stableObject,
|
|
32
|
+
r.canonical?.normalizedSignatureHash ?? "",
|
|
33
|
+
);
|
|
34
|
+
routineToStable.set(r.id as unknown as string, stable);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const remap = (id: string): string => routineToStable.get(id) ?? id;
|
|
38
|
+
|
|
39
|
+
const out: GraphEdge[] = [];
|
|
40
|
+
for (const e of model.typedEdges ?? []) {
|
|
41
|
+
// Shallow-copy and rewrite from/to where present.
|
|
42
|
+
const copy = { ...e } as GraphEdge;
|
|
43
|
+
if ("from" in copy && typeof copy.from === "string") {
|
|
44
|
+
(copy as { from: string }).from = remap(copy.from);
|
|
45
|
+
}
|
|
46
|
+
if ("to" in copy && typeof copy.to === "string") {
|
|
47
|
+
(copy as { to: string }).to = remap(copy.to);
|
|
48
|
+
}
|
|
49
|
+
out.push(copy);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
out.sort((a, b) => edgeKey(a).localeCompare(edgeKey(b)));
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function edgeKey(e: GraphEdge): string {
|
|
57
|
+
const from = "from" in e ? String(e.from ?? "") : "";
|
|
58
|
+
const to = "to" in e ? String((e as { to?: unknown }).to ?? "") : "";
|
|
59
|
+
return [e.kind, from, to].join("|");
|
|
60
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { SnapshotInput } from "../types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SHA-256 over the sorted (kind, path, contentHash) triples + alsemVersion.
|
|
6
|
+
* Stable across runs given the same inputs + version; differs when ANY input
|
|
7
|
+
* contentHash or the alsem version changes.
|
|
8
|
+
*/
|
|
9
|
+
export function computeWorkspaceFingerprint(
|
|
10
|
+
inputs: readonly SnapshotInput[],
|
|
11
|
+
alsemVersion: string,
|
|
12
|
+
): string {
|
|
13
|
+
const sorted = [...inputs].sort((a, b) =>
|
|
14
|
+
`${a.kind}|${a.path}`.localeCompare(`${b.kind}|${b.path}`),
|
|
15
|
+
);
|
|
16
|
+
const lines = sorted.map((i) => `${i.kind}\t${i.path}\t${i.contentHash}`);
|
|
17
|
+
lines.push(`alsemVersion\t${alsemVersion}`);
|
|
18
|
+
return createHash("sha256").update(lines.join("\n"), "utf8").digest("hex");
|
|
19
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { decode as cborDecode } from "cbor-x";
|
|
2
|
+
import { type CapabilitySnapshot, SNAPSHOT_SCHEMA_VERSION, type SnapshotFormat } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Deserialize a snapshot from bytes. Auto-detects format unless `formatHint`
|
|
6
|
+
* is provided:
|
|
7
|
+
* - first byte 0x7b ('{') → JSON
|
|
8
|
+
* - first two bytes 0x1f 0x8b → gzip → un-gzip → CBOR
|
|
9
|
+
* - otherwise → CBOR
|
|
10
|
+
*
|
|
11
|
+
* Asserts schemaVersion. Phase 0c only accepts 1; future versions add
|
|
12
|
+
* migration shims here.
|
|
13
|
+
*/
|
|
14
|
+
export function deserializeSnapshot(
|
|
15
|
+
bytes: Uint8Array,
|
|
16
|
+
formatHint?: SnapshotFormat,
|
|
17
|
+
): CapabilitySnapshot {
|
|
18
|
+
const fmt = formatHint ?? detectFormat(bytes);
|
|
19
|
+
let parsed: unknown;
|
|
20
|
+
if (fmt === "json") {
|
|
21
|
+
parsed = JSON.parse(new TextDecoder().decode(bytes));
|
|
22
|
+
} else if (fmt === "cbor.gz") {
|
|
23
|
+
parsed = cborDecode(Bun.gunzipSync(bytes as unknown as ArrayBuffer));
|
|
24
|
+
} else {
|
|
25
|
+
parsed = cborDecode(bytes);
|
|
26
|
+
}
|
|
27
|
+
const snap = parsed as CapabilitySnapshot;
|
|
28
|
+
if (snap.schemaVersion !== SNAPSHOT_SCHEMA_VERSION) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`deserializeSnapshot: unknown schemaVersion ${snap.schemaVersion} (this build only handles ${SNAPSHOT_SCHEMA_VERSION})`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return snap;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function detectFormat(bytes: Uint8Array): SnapshotFormat {
|
|
37
|
+
if (bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) return "cbor.gz";
|
|
38
|
+
if (bytes.length >= 1 && bytes[0] === 0x7b) return "json";
|
|
39
|
+
return "cbor";
|
|
40
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { serializeCbor } from "./serialize-cbor.ts";
|
|
2
|
+
import type { CapabilitySnapshot } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CBOR + gzip serializer. Wraps the CBOR output with Bun's built-in
|
|
6
|
+
* gzipSync. Byte-stable; smaller than uncompressed CBOR; starts with
|
|
7
|
+
* the gzip magic bytes 0x1f 0x8b used by the deserializer auto-detect.
|
|
8
|
+
*/
|
|
9
|
+
export function serializeCborGz(snapshot: CapabilitySnapshot): Uint8Array {
|
|
10
|
+
const cbor = serializeCbor(snapshot);
|
|
11
|
+
return Bun.gzipSync(cbor as unknown as ArrayBuffer) as Uint8Array;
|
|
12
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Encoder } from "cbor-x";
|
|
2
|
+
import type { CapabilitySnapshot } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
// Pinned encoder options for deterministic output.
|
|
5
|
+
const encoder = new Encoder({
|
|
6
|
+
useRecords: false,
|
|
7
|
+
mapsAsObjects: true,
|
|
8
|
+
pack: false,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* CBOR serializer. Returns a Uint8Array.
|
|
13
|
+
*
|
|
14
|
+
* Deterministic: encoder options pinned; cbor-x's tag tables and key order
|
|
15
|
+
* are stable for plain JS objects. Round-trip tested in Task 18 deserializer.
|
|
16
|
+
*/
|
|
17
|
+
export function serializeCbor(snapshot: CapabilitySnapshot): Uint8Array {
|
|
18
|
+
return encoder.encode(snapshot);
|
|
19
|
+
}
|