foottrace 1.0.0 → 1.0.2

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/README.md CHANGED
@@ -4,7 +4,7 @@ Execution records and the tools to read them: replay, time travel, slices, and e
4
4
 
5
5
  This package extracts FootPrint's established record layer. It does not introduce a second writer or a different replay law. The first release preserves the frozen record fixtures from footprintjs 9.48.1 and includes the coordinated 9.48.2 copy-safety correction: an own `__proto__` payload field stays data, never the copy's prototype. See the [changelog](CHANGELOG.md) for the limited rich-value behavior change.
6
6
 
7
- **Release status:** extraction candidate. Version 1.0.0 is not ready to publish until review and release checks are complete.
7
+ Foottrace owns the record layer used by footprintjs 10. Older stored records remain readable without installing their original engine.
8
8
 
9
9
  ## Three entry points
10
10
 
@@ -257,6 +257,12 @@ export type KeysReadLookup = (runtimeStageId: string) => string[];
257
257
  * @returns Root CausalNode with .parents forming the DAG, or undefined if startId not found
258
258
  */
259
259
  export declare function causalChain(commitLog: CommitBundle[], startId: string, getKeysRead: KeysReadLookup, options?: CausalChainOptions): CausalNode | undefined;
260
+ /**
261
+ * Internal slice composition: keep the actual bundle selected by the writer
262
+ * query (including a nonempty continuation). This is not a public entry point.
263
+ * The read position still belongs to the execution's first commit.
264
+ */
265
+ export declare function causalChainFromCommit(commitLog: CommitBundle[], writer: CommitBundle, getKeysRead: KeysReadLookup, options?: CausalChainOptions): CausalNode | undefined;
260
266
  /**
261
267
  * Flatten the causal DAG into a BFS-ordered list of nodes.
262
268
  * Each node appears exactly once (first occurrence by BFS order).
@@ -56,7 +56,7 @@
56
56
  * console.log(formatCausalChain(dag)); // human-readable
57
57
  * ```
58
58
  */
59
- import { findLastWriter } from './commitLogUtils.js';
59
+ import { buildCommitIndex, findLastWriter } from './commitLogUtils.js';
60
60
  import { queryWork, relation, writesOnlyInside } from './keyPaths.js';
61
61
  import { logModel } from './logModel.js';
62
62
  // ── Staged optimization: writer lookup strategies ──────────────────────
@@ -148,6 +148,17 @@ function incompleteSourcesFragment(commit) {
148
148
  * @returns Root CausalNode with .parents forming the DAG, or undefined if startId not found
149
149
  */
150
150
  export function causalChain(commitLog, startId, getKeysRead, options) {
151
+ return buildCausalChain(commitLog, startId, getKeysRead, options);
152
+ }
153
+ /**
154
+ * Internal slice composition: keep the actual bundle selected by the writer
155
+ * query (including a nonempty continuation). This is not a public entry point.
156
+ * The read position still belongs to the execution's first commit.
157
+ */
158
+ export function causalChainFromCommit(commitLog, writer, getKeysRead, options) {
159
+ return buildCausalChain(commitLog, writer.runtimeStageId, getKeysRead, options, writer);
160
+ }
161
+ function buildCausalChain(commitLog, startId, getKeysRead, options, selectedWriter) {
151
162
  const maxDepth = options?.maxDepth ?? 20;
152
163
  const maxNodes = options?.maxNodes ?? 100;
153
164
  const controlDeps = options?.controlDeps;
@@ -158,16 +169,15 @@ export function causalChain(commitLog, startId, getKeysRead, options) {
158
169
  // never mistake a truncated slice for a complete one.
159
170
  let truncatedByDepth = false;
160
171
  let truncatedByNodes = false;
161
- // Build position index: runtimeStageId → array position (O(n) once)
162
- const idxMap = new Map();
163
- for (let i = 0; i < commitLog.length; i++) {
164
- idxMap.set(commitLog[i].runtimeStageId, i);
165
- }
172
+ // A continuation may write more data, but it is not another execution of
173
+ // the stage's reads. Use the shared FIRST-commit rule for every read cutoff;
174
+ // keep writer bundles separately for their write metadata and read prefixes.
175
+ const idxMap = buildCommitIndex(commitLog);
166
176
  queryWork.units += commitLog.length;
167
177
  const startIdx = idxMap.get(startId);
168
178
  if (startIdx === undefined)
169
179
  return undefined;
170
- const startCommit = commitLog[startIdx];
180
+ const startCommit = selectedWriter ?? commitLog[startIdx];
171
181
  // Pick writer lookup strategy based on log size
172
182
  const findWriter = createWriterLookup(commitLog);
173
183
  // Node dedup map: runtimeStageId → CausalNode (ensures DAG, not tree)
@@ -9,7 +9,6 @@
9
9
  * the call site instead: `(id) => recorder.getByKey(id)?.keysRead ?? []`.
10
10
  */
11
11
  import type { KeysReadLookup } from '../memory/backtrack.js';
12
- import type { ExecutionTree } from '../memory/types.js';
13
12
  import type { KeysReadSource } from './types.js';
14
13
  /**
15
14
  * Post-hoc reads from a finished run's execution tree — ZERO setup.
@@ -36,8 +35,17 @@ import type { KeysReadSource } from './types.js';
36
35
  * Passing multiple trees widens READ resolution only (useful when one log
37
36
  * genuinely spans them); it does not let a root-log slice cross a subflow
38
37
  * mount — see README.md § Subflow boundaries.
38
+ *
39
+ * A live {@link ExecutionTree} or unknown saved JSON uses this same traversal.
40
+ * Malformed reader fields throw a TypeError naming their source path; no partial
41
+ * reads strategy is returned. Optional fields may be absent or undefined, but
42
+ * null nodes, sparse arrays and wrongly shaped fields are not missing evidence
43
+ * to silently skip. Only read-map KEYS are inspected, never their stored values.
44
+ * Cycles/shared objects are visited once; existing traversal order is retained.
45
+ * Coverage counts observed nodes and nonempty read sets, not capture completeness.
46
+ * This is an ordinary-data boundary, not a sandbox for hostile proxies/accessors.
39
47
  */
40
- export declare function keysReadFromExecutionTree(tree: ExecutionTree | readonly ExecutionTree[]): KeysReadSource;
48
+ export declare function keysReadFromExecutionTree(tree: unknown): KeysReadSource;
41
49
  /**
42
50
  * Reads from a prebuilt map — e.g. collected live from
43
51
  * `ScopeRecorder.onRead` events, or deserialized from a stored trace.
@@ -8,6 +8,10 @@
8
8
  * drag this leaf library up the dependency DAG. The adapter is a one-liner at
9
9
  * the call site instead: `(id) => recorder.getByKey(id)?.keysRead ?? []`.
10
10
  */
11
+ /** Narrow only the object shape this reader uses; stored values stay opaque. */
12
+ function isReadTreeObject(value) {
13
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
14
+ }
11
15
  /**
12
16
  * Post-hoc reads from a finished run's execution tree — ZERO setup.
13
17
  *
@@ -33,33 +37,63 @@
33
37
  * Passing multiple trees widens READ resolution only (useful when one log
34
38
  * genuinely spans them); it does not let a root-log slice cross a subflow
35
39
  * mount — see README.md § Subflow boundaries.
40
+ *
41
+ * A live {@link ExecutionTree} or unknown saved JSON uses this same traversal.
42
+ * Malformed reader fields throw a TypeError naming their source path; no partial
43
+ * reads strategy is returned. Optional fields may be absent or undefined, but
44
+ * null nodes, sparse arrays and wrongly shaped fields are not missing evidence
45
+ * to silently skip. Only read-map KEYS are inspected, never their stored values.
46
+ * Cycles/shared objects are visited once; existing traversal order is retained.
47
+ * Coverage counts observed nodes and nonempty read sets, not capture completeness.
48
+ * This is an ordinary-data boundary, not a sandbox for hostile proxies/accessors.
36
49
  */
37
50
  export function keysReadFromExecutionTree(tree) {
38
51
  const byStep = new Map();
39
52
  let steps = 0;
40
53
  // The tree is acyclic by construction (next/children), but this walker is
41
- // also handed CONSUMER-provided data — a visited set makes a malformed or
54
+ // also handed CONSUMER-provided data — a visited set makes a cycle in a
42
55
  // hand-built tree a non-event instead of an infinite loop.
43
56
  const visited = new Set();
44
- const stack = [tree].flat(); // one root, or several (subflow trees); a hole is skipped
57
+ const stack = [];
58
+ if (Array.isArray(tree)) {
59
+ // Indexing retains holes as invalid evidence, unlike flat()/map().
60
+ for (let i = 0; i < tree.length; i++)
61
+ stack.push({ node: tree[i], path: `$[${i}]` });
62
+ }
63
+ else {
64
+ stack.push({ node: tree, path: '$' });
65
+ }
45
66
  while (stack.length > 0) {
46
- const node = stack.pop();
67
+ const { node, path } = stack.pop();
68
+ if (!isReadTreeObject(node))
69
+ throw new TypeError(`keysReadFromExecutionTree: ${path} must be an object`);
47
70
  if (visited.has(node))
48
71
  continue;
49
72
  visited.add(node);
50
- if (node.runtimeStageId) {
73
+ const { runtimeStageId, stageReads, next, children } = node;
74
+ if (runtimeStageId !== undefined && typeof runtimeStageId !== 'string') {
75
+ throw new TypeError(`keysReadFromExecutionTree: ${path}.runtimeStageId must be a string when present`);
76
+ }
77
+ if (stageReads !== undefined && !isReadTreeObject(stageReads)) {
78
+ throw new TypeError(`keysReadFromExecutionTree: ${path}.stageReads must be an object when present`);
79
+ }
80
+ if (children !== undefined && !Array.isArray(children)) {
81
+ throw new TypeError(`keysReadFromExecutionTree: ${path}.children must be an array when present`);
82
+ }
83
+ if (runtimeStageId) {
51
84
  steps++;
52
- if (node.stageReads) {
53
- const keys = Object.keys(node.stageReads);
85
+ if (stageReads !== undefined) {
86
+ const keys = Object.keys(stageReads);
54
87
  if (keys.length > 0)
55
- byStep.set(node.runtimeStageId, keys);
88
+ byStep.set(runtimeStageId, keys);
56
89
  }
57
90
  }
58
- if (node.next)
59
- stack.push(node.next);
60
- if (node.children)
61
- for (const c of node.children)
62
- stack.push(c);
91
+ if (next !== undefined)
92
+ stack.push({ node: next, path: `${path}.next` });
93
+ if (children !== undefined) {
94
+ for (let i = 0; i < children.length; i++)
95
+ stack.push({ node: children[i], path: `${path}.children[${i}]` });
96
+ }
63
97
  }
64
98
  return {
65
99
  kind: 'execution-tree',
@@ -12,7 +12,7 @@
12
12
  * it, every surface hand-rolls the anchor step differently and their answers
13
13
  * disagree. The value of this function is that they can't.
14
14
  */
15
- import { causalChain } from '../memory/backtrack.js';
15
+ import { causalChainFromCommit } from '../memory/backtrack.js';
16
16
  import { commitValueAtWithBasis, findLastWriterWithBasis } from '../memory/commitLogUtils.js';
17
17
  import { normaliseStateKey } from '../memory/paths.js';
18
18
  import { fromInitialStateNote, nestedRowsNote, redactedValueNote } from './keyIndex.js';
@@ -69,7 +69,9 @@ export function sliceForKey(commitLog, key, keysRead, options) {
69
69
  // without `TraceEntry.readKeys` (the writeProvenance dial off) fall back
70
70
  // to stage-level expansion per node, byte-identically. Consumers can force
71
71
  // 'stage' via options.
72
- const root = causalChain(commitLog, writer.runtimeStageId, source.lookup, {
72
+ // Preserve this exact writer's metadata/read prefix, even when it is a
73
+ // continuation. Causal expansion separately uses the execution's first commit.
74
+ const root = causalChainFromCommit(commitLog, writer, source.lookup, {
73
75
  ...options,
74
76
  edgeAttribution: options?.edgeAttribution ?? 'per-write',
75
77
  rootLinkKeys: options?.rootLinkKeys ?? [normalisedKey],
@@ -58,7 +58,7 @@
58
58
  * ```
59
59
  */
60
60
  Object.defineProperty(exports, "__esModule", { value: true });
61
- exports._REVERSE_INDEX_THRESHOLD = exports.formatCausalChain = exports.flattenCausalDAG = exports.causalChain = void 0;
61
+ exports._REVERSE_INDEX_THRESHOLD = exports.formatCausalChain = exports.flattenCausalDAG = exports.causalChainFromCommit = exports.causalChain = void 0;
62
62
  const commitLogUtils_js_1 = require("./commitLogUtils.js");
63
63
  const keyPaths_js_1 = require("./keyPaths.js");
64
64
  const logModel_js_1 = require("./logModel.js");
@@ -151,6 +151,19 @@ function incompleteSourcesFragment(commit) {
151
151
  * @returns Root CausalNode with .parents forming the DAG, or undefined if startId not found
152
152
  */
153
153
  function causalChain(commitLog, startId, getKeysRead, options) {
154
+ return buildCausalChain(commitLog, startId, getKeysRead, options);
155
+ }
156
+ exports.causalChain = causalChain;
157
+ /**
158
+ * Internal slice composition: keep the actual bundle selected by the writer
159
+ * query (including a nonempty continuation). This is not a public entry point.
160
+ * The read position still belongs to the execution's first commit.
161
+ */
162
+ function causalChainFromCommit(commitLog, writer, getKeysRead, options) {
163
+ return buildCausalChain(commitLog, writer.runtimeStageId, getKeysRead, options, writer);
164
+ }
165
+ exports.causalChainFromCommit = causalChainFromCommit;
166
+ function buildCausalChain(commitLog, startId, getKeysRead, options, selectedWriter) {
154
167
  const maxDepth = options?.maxDepth ?? 20;
155
168
  const maxNodes = options?.maxNodes ?? 100;
156
169
  const controlDeps = options?.controlDeps;
@@ -161,16 +174,15 @@ function causalChain(commitLog, startId, getKeysRead, options) {
161
174
  // never mistake a truncated slice for a complete one.
162
175
  let truncatedByDepth = false;
163
176
  let truncatedByNodes = false;
164
- // Build position index: runtimeStageId → array position (O(n) once)
165
- const idxMap = new Map();
166
- for (let i = 0; i < commitLog.length; i++) {
167
- idxMap.set(commitLog[i].runtimeStageId, i);
168
- }
177
+ // A continuation may write more data, but it is not another execution of
178
+ // the stage's reads. Use the shared FIRST-commit rule for every read cutoff;
179
+ // keep writer bundles separately for their write metadata and read prefixes.
180
+ const idxMap = (0, commitLogUtils_js_1.buildCommitIndex)(commitLog);
169
181
  keyPaths_js_1.queryWork.units += commitLog.length;
170
182
  const startIdx = idxMap.get(startId);
171
183
  if (startIdx === undefined)
172
184
  return undefined;
173
- const startCommit = commitLog[startIdx];
185
+ const startCommit = selectedWriter ?? commitLog[startIdx];
174
186
  // Pick writer lookup strategy based on log size
175
187
  const findWriter = createWriterLookup(commitLog);
176
188
  // Node dedup map: runtimeStageId → CausalNode (ensures DAG, not tree)
@@ -374,7 +386,6 @@ function causalChain(commitLog, startId, getKeysRead, options) {
374
386
  }
375
387
  return root;
376
388
  }
377
- exports.causalChain = causalChain;
378
389
  // ── Utilities ──────────────────────────────────────────────────────────
379
390
  /**
380
391
  * Flatten the causal DAG into a BFS-ordered list of nodes.
@@ -11,6 +11,10 @@
11
11
  */
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
13
  exports.resolveKeysReadSource = exports.keysReadFromMap = exports.keysReadFromExecutionTree = void 0;
14
+ /** Narrow only the object shape this reader uses; stored values stay opaque. */
15
+ function isReadTreeObject(value) {
16
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
17
+ }
14
18
  /**
15
19
  * Post-hoc reads from a finished run's execution tree — ZERO setup.
16
20
  *
@@ -36,33 +40,63 @@ exports.resolveKeysReadSource = exports.keysReadFromMap = exports.keysReadFromEx
36
40
  * Passing multiple trees widens READ resolution only (useful when one log
37
41
  * genuinely spans them); it does not let a root-log slice cross a subflow
38
42
  * mount — see README.md § Subflow boundaries.
43
+ *
44
+ * A live {@link ExecutionTree} or unknown saved JSON uses this same traversal.
45
+ * Malformed reader fields throw a TypeError naming their source path; no partial
46
+ * reads strategy is returned. Optional fields may be absent or undefined, but
47
+ * null nodes, sparse arrays and wrongly shaped fields are not missing evidence
48
+ * to silently skip. Only read-map KEYS are inspected, never their stored values.
49
+ * Cycles/shared objects are visited once; existing traversal order is retained.
50
+ * Coverage counts observed nodes and nonempty read sets, not capture completeness.
51
+ * This is an ordinary-data boundary, not a sandbox for hostile proxies/accessors.
39
52
  */
40
53
  function keysReadFromExecutionTree(tree) {
41
54
  const byStep = new Map();
42
55
  let steps = 0;
43
56
  // The tree is acyclic by construction (next/children), but this walker is
44
- // also handed CONSUMER-provided data — a visited set makes a malformed or
57
+ // also handed CONSUMER-provided data — a visited set makes a cycle in a
45
58
  // hand-built tree a non-event instead of an infinite loop.
46
59
  const visited = new Set();
47
- const stack = [tree].flat(); // one root, or several (subflow trees); a hole is skipped
60
+ const stack = [];
61
+ if (Array.isArray(tree)) {
62
+ // Indexing retains holes as invalid evidence, unlike flat()/map().
63
+ for (let i = 0; i < tree.length; i++)
64
+ stack.push({ node: tree[i], path: `$[${i}]` });
65
+ }
66
+ else {
67
+ stack.push({ node: tree, path: '$' });
68
+ }
48
69
  while (stack.length > 0) {
49
- const node = stack.pop();
70
+ const { node, path } = stack.pop();
71
+ if (!isReadTreeObject(node))
72
+ throw new TypeError(`keysReadFromExecutionTree: ${path} must be an object`);
50
73
  if (visited.has(node))
51
74
  continue;
52
75
  visited.add(node);
53
- if (node.runtimeStageId) {
76
+ const { runtimeStageId, stageReads, next, children } = node;
77
+ if (runtimeStageId !== undefined && typeof runtimeStageId !== 'string') {
78
+ throw new TypeError(`keysReadFromExecutionTree: ${path}.runtimeStageId must be a string when present`);
79
+ }
80
+ if (stageReads !== undefined && !isReadTreeObject(stageReads)) {
81
+ throw new TypeError(`keysReadFromExecutionTree: ${path}.stageReads must be an object when present`);
82
+ }
83
+ if (children !== undefined && !Array.isArray(children)) {
84
+ throw new TypeError(`keysReadFromExecutionTree: ${path}.children must be an array when present`);
85
+ }
86
+ if (runtimeStageId) {
54
87
  steps++;
55
- if (node.stageReads) {
56
- const keys = Object.keys(node.stageReads);
88
+ if (stageReads !== undefined) {
89
+ const keys = Object.keys(stageReads);
57
90
  if (keys.length > 0)
58
- byStep.set(node.runtimeStageId, keys);
91
+ byStep.set(runtimeStageId, keys);
59
92
  }
60
93
  }
61
- if (node.next)
62
- stack.push(node.next);
63
- if (node.children)
64
- for (const c of node.children)
65
- stack.push(c);
94
+ if (next !== undefined)
95
+ stack.push({ node: next, path: `${path}.next` });
96
+ if (children !== undefined) {
97
+ for (let i = 0; i < children.length; i++)
98
+ stack.push({ node: children[i], path: `${path}.children[${i}]` });
99
+ }
66
100
  }
67
101
  return {
68
102
  kind: 'execution-tree',
@@ -72,7 +72,9 @@ function sliceForKey(commitLog, key, keysRead, options) {
72
72
  // without `TraceEntry.readKeys` (the writeProvenance dial off) fall back
73
73
  // to stage-level expansion per node, byte-identically. Consumers can force
74
74
  // 'stage' via options.
75
- const root = (0, backtrack_js_1.causalChain)(commitLog, writer.runtimeStageId, source.lookup, {
75
+ // Preserve this exact writer's metadata/read prefix, even when it is a
76
+ // continuation. Causal expansion separately uses the execution's first commit.
77
+ const root = (0, backtrack_js_1.causalChainFromCommit)(commitLog, writer, source.lookup, {
76
78
  ...options,
77
79
  edgeAttribution: options?.edgeAttribution ?? 'per-write',
78
80
  rootLinkKeys: options?.rootLinkKeys ?? [normalisedKey],
@@ -257,6 +257,12 @@ export type KeysReadLookup = (runtimeStageId: string) => string[];
257
257
  * @returns Root CausalNode with .parents forming the DAG, or undefined if startId not found
258
258
  */
259
259
  export declare function causalChain(commitLog: CommitBundle[], startId: string, getKeysRead: KeysReadLookup, options?: CausalChainOptions): CausalNode | undefined;
260
+ /**
261
+ * Internal slice composition: keep the actual bundle selected by the writer
262
+ * query (including a nonempty continuation). This is not a public entry point.
263
+ * The read position still belongs to the execution's first commit.
264
+ */
265
+ export declare function causalChainFromCommit(commitLog: CommitBundle[], writer: CommitBundle, getKeysRead: KeysReadLookup, options?: CausalChainOptions): CausalNode | undefined;
260
266
  /**
261
267
  * Flatten the causal DAG into a BFS-ordered list of nodes.
262
268
  * Each node appears exactly once (first occurrence by BFS order).
@@ -9,7 +9,6 @@
9
9
  * the call site instead: `(id) => recorder.getByKey(id)?.keysRead ?? []`.
10
10
  */
11
11
  import type { KeysReadLookup } from '../memory/backtrack.js';
12
- import type { ExecutionTree } from '../memory/types.js';
13
12
  import type { KeysReadSource } from './types.js';
14
13
  /**
15
14
  * Post-hoc reads from a finished run's execution tree — ZERO setup.
@@ -36,8 +35,17 @@ import type { KeysReadSource } from './types.js';
36
35
  * Passing multiple trees widens READ resolution only (useful when one log
37
36
  * genuinely spans them); it does not let a root-log slice cross a subflow
38
37
  * mount — see README.md § Subflow boundaries.
38
+ *
39
+ * A live {@link ExecutionTree} or unknown saved JSON uses this same traversal.
40
+ * Malformed reader fields throw a TypeError naming their source path; no partial
41
+ * reads strategy is returned. Optional fields may be absent or undefined, but
42
+ * null nodes, sparse arrays and wrongly shaped fields are not missing evidence
43
+ * to silently skip. Only read-map KEYS are inspected, never their stored values.
44
+ * Cycles/shared objects are visited once; existing traversal order is retained.
45
+ * Coverage counts observed nodes and nonempty read sets, not capture completeness.
46
+ * This is an ordinary-data boundary, not a sandbox for hostile proxies/accessors.
39
47
  */
40
- export declare function keysReadFromExecutionTree(tree: ExecutionTree | readonly ExecutionTree[]): KeysReadSource;
48
+ export declare function keysReadFromExecutionTree(tree: unknown): KeysReadSource;
41
49
  /**
42
50
  * Reads from a prebuilt map — e.g. collected live from
43
51
  * `ScopeRecorder.onRead` events, or deserialized from a stored trace.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "foottrace",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "type": "commonjs",
5
5
  "description": "Execution records, replay, time travel and honest attribution — independent of the engine that produced them",
6
6
  "license": "MIT",