@mmnto/totem 1.26.1 → 1.28.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.
Files changed (48) hide show
  1. package/dist/compile-manifest.d.ts +2 -2
  2. package/dist/config-schema.d.ts +29 -12
  3. package/dist/config-schema.d.ts.map +1 -1
  4. package/dist/config-schema.js +16 -0
  5. package/dist/config-schema.js.map +1 -1
  6. package/dist/config-schema.test.js +29 -0
  7. package/dist/config-schema.test.js.map +1 -1
  8. package/dist/errors.d.ts +1 -1
  9. package/dist/errors.d.ts.map +1 -1
  10. package/dist/errors.js.map +1 -1
  11. package/dist/index.d.ts +5 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +3 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/pack-discovery.d.ts +26 -0
  16. package/dist/pack-discovery.d.ts.map +1 -1
  17. package/dist/pack-discovery.js +22 -1
  18. package/dist/pack-discovery.js.map +1 -1
  19. package/dist/pack-manifest-writer.d.ts +5 -0
  20. package/dist/pack-manifest-writer.d.ts.map +1 -1
  21. package/dist/pack-manifest-writer.js +12 -1
  22. package/dist/pack-manifest-writer.js.map +1 -1
  23. package/dist/pack-manifest-writer.test.js +41 -0
  24. package/dist/pack-manifest-writer.test.js.map +1 -1
  25. package/dist/rule-engine.d.ts.map +1 -1
  26. package/dist/rule-engine.js +17 -0
  27. package/dist/rule-engine.js.map +1 -1
  28. package/dist/rule-engine.test.js +101 -1
  29. package/dist/rule-engine.test.js.map +1 -1
  30. package/dist/stale-manifest.d.ts +72 -0
  31. package/dist/stale-manifest.d.ts.map +1 -0
  32. package/dist/stale-manifest.js +119 -0
  33. package/dist/stale-manifest.js.map +1 -0
  34. package/dist/stale-manifest.test.d.ts +15 -0
  35. package/dist/stale-manifest.test.d.ts.map +1 -0
  36. package/dist/stale-manifest.test.js +164 -0
  37. package/dist/stale-manifest.test.js.map +1 -0
  38. package/dist/store/lance-schema.d.ts +2 -2
  39. package/dist/substrate-resolver.d.ts +76 -0
  40. package/dist/substrate-resolver.d.ts.map +1 -0
  41. package/dist/substrate-resolver.js +216 -0
  42. package/dist/substrate-resolver.js.map +1 -0
  43. package/dist/substrate-resolver.test.d.ts +11 -0
  44. package/dist/substrate-resolver.test.d.ts.map +1 -0
  45. package/dist/substrate-resolver.test.js +406 -0
  46. package/dist/substrate-resolver.test.js.map +1 -0
  47. package/dist/types.d.ts +2 -2
  48. package/package.json +1 -1
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Substrate-path resolver (mmnto-ai/totem#1820, ADR-100 Phase C).
3
+ *
4
+ * Single source of truth for "where are the substrate `.handoff/` and
5
+ * `.journal/` directories on disk." After ADR-100, both directories live
6
+ * in a sibling `mmnto-ai/totem-substrate` repo; the original in-repo paths
7
+ * are sediment-frozen per ADR-100 Q7C and serve as fallback during the
8
+ * sediment window.
9
+ *
10
+ * Resolution walks four precedence layers:
11
+ *
12
+ * 1. **env** — `TOTEM_SUBSTRATE_PATH`.
13
+ * 2. **config** — `TotemConfig.substratePath`.
14
+ * 3. **sibling-walk** — walk up to 3 levels from `configRoot` looking for
15
+ * `<parent>/totem-substrate/`.
16
+ * 4. **repo-local sediment** — `<configRoot>/.handoff/` and
17
+ * `<configRoot>/.journal/`.
18
+ *
19
+ * Layers 1-3 require full substrate shape (a git metadata subdir plus
20
+ * `.handoff/` and `.journal/` subdirs) to gate against stale empty
21
+ * clones. Layer 4 (sediment) accepts partial state — `.handoff/` alone
22
+ * OR `.journal/` alone is valid and returns the populated dir with the
23
+ * missing one as null.
24
+ *
25
+ * Returns a `SubstratePaths` record whose `source` field discriminates
26
+ * the resolution outcome. ADR-090 graceful degradation: if all four
27
+ * layers fail, returns `{ handoffRoot: null, journalRoot: null,
28
+ * source: 'none' }`. Consumers handle null per their existing contract.
29
+ *
30
+ * Pure utility. No caching, no side effects, no logging — same stance as
31
+ * `resolveStrategyRoot` (PR mmnto-ai/totem#1743). Each call walks the chain
32
+ * from scratch so a process that mutates `process.env` mid-run sees the
33
+ * new value next call.
34
+ */
35
+ import * as fs from 'node:fs';
36
+ import * as path from 'node:path';
37
+ import { resolveGitRoot } from './sys/git.js';
38
+ const ENV_VAR = 'TOTEM_SUBSTRATE_PATH';
39
+ const SIBLING_DIRNAME = 'totem-substrate';
40
+ const SIBLING_WALK_MAX_DEPTH = 3;
41
+ /**
42
+ * Substrate shape predicate — a directory qualifies as a real substrate
43
+ * clone only when it carries the git metadata subdir AND both the
44
+ * handoff and journal subdirs. Used by layers 1-3 (env, config,
45
+ * sibling-walk) to reject stale empty directories that would otherwise
46
+ * satisfy a plain `isDirectory` check.
47
+ *
48
+ * Layer 4 (repo-local sediment) does NOT use this — sediment lives
49
+ * inside the product repo so the nested git metadata isn't there.
50
+ */
51
+ function validateSubstrateShape(dir) {
52
+ // totem-context: shape gate (substrate clone detection), not a gitRoot probe — the rule that flags raw git-metadata-dir checks is targeted at resolveGitRoot-style probing, not clone-shape detection.
53
+ // `fs.existsSync` (not `isDirectory`) handles submodule + linked-worktree
54
+ // setups where the git metadata path is a pointer file rather than a
55
+ // directory. Per GCA review on mmnto-ai/totem#1821.
56
+ return (fs.existsSync(path.join(dir, '.git')) && // totem-context: shape gate, not gitRoot probe; submodule/worktree compat.
57
+ isDirectory(path.join(dir, '.handoff')) &&
58
+ isDirectory(path.join(dir, '.journal')));
59
+ }
60
+ /**
61
+ * `fs.statSync` raises on missing paths and on EACCES/ENOTDIR; treat any
62
+ * stat failure as "not a directory" and let the resolver fall through to
63
+ * the next layer. Matches the `resolveStrategyRoot` pattern.
64
+ */
65
+ function isDirectory(p) {
66
+ try {
67
+ return fs.statSync(p).isDirectory();
68
+ // totem-context: intentional fall-through — stat failures (ENOENT, EACCES, ENOTDIR) are the precedence-chain "miss" signal; rethrowing would force every consumer to wrap the resolver in try/catch for a routine outcome.
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ }
74
+ /**
75
+ * Read `TOTEM_SUBSTRATE_PATH` from the env map. Whitespace-only values
76
+ * are treated as unset so a `TOTEM_SUBSTRATE_PATH=" "` accident does
77
+ * not short-circuit the precedence chain.
78
+ */
79
+ function readEnvValue(env) {
80
+ const raw = env[ENV_VAR];
81
+ if (typeof raw === 'string' && raw.trim().length > 0)
82
+ return raw;
83
+ return undefined;
84
+ }
85
+ /**
86
+ * Resolve a raw value (env or config) to an absolute path anchored at
87
+ * `configRoot`. Absolute values are returned normalized as-is; relative
88
+ * values are joined against the anchor. Mirrors `resolveStrategyRoot`'s
89
+ * `resolveValue` pattern.
90
+ */
91
+ function resolveValue(raw, anchor) {
92
+ const trimmed = raw.trim();
93
+ if (path.isAbsolute(trimmed))
94
+ return path.normalize(trimmed);
95
+ return path.normalize(path.join(anchor, trimmed));
96
+ }
97
+ /**
98
+ * Build the substrate-shaped `SubstratePaths` record for a resolved
99
+ * substrate directory. Both paths populated; source is 'substrate'.
100
+ */
101
+ function substrateResult(dir) {
102
+ return {
103
+ handoffRoot: path.normalize(path.join(dir, '.handoff')),
104
+ journalRoot: path.normalize(path.join(dir, '.journal')),
105
+ source: 'substrate',
106
+ };
107
+ }
108
+ /**
109
+ * Walk the four-layer precedence chain. Returns a `SubstratePaths` record
110
+ * whose `source` discriminates the resolution outcome.
111
+ *
112
+ * Layer order: env → config → sibling-walk (up to 3 levels from
113
+ * `configRoot`) → repo-local sediment (`<configRoot>/.handoff/` and
114
+ * `<configRoot>/.journal/`).
115
+ *
116
+ * @param configRoot Anchor for relative env / config values and start
117
+ * of the sibling-walk. Typically the directory containing
118
+ * `totem.config.ts` (the project root).
119
+ */
120
+ export function resolveSubstratePaths(configRoot, options = {}) {
121
+ const env = options.env ?? process.env;
122
+ const config = options.config;
123
+ // Two distinct anchors per CR review on PR mmnto-ai/totem#1821:
124
+ //
125
+ // - `gitAnchor` (lazy, repo-wide) — anchors relative env/config values
126
+ // and the sibling-walk start. Mirrors `resolveStrategyRoot` (PR
127
+ // mmnto-ai/totem#1743): `options.gitRoot` test seam →
128
+ // `resolveGitRoot(configRoot)` probe → `configAnchor` fallback. The
129
+ // user's intent for a relative `TOTEM_SUBSTRATE_PATH=../...` is
130
+ // "relative to my repo," not "relative to my subpackage."
131
+ //
132
+ // - `configAnchor` (eager, caller-scoped) — anchors layer 4 (repo-local
133
+ // sediment). Per the trigger spec, sediment lives at
134
+ // `<configRoot>/.handoff/` and `<configRoot>/.journal/`. In a
135
+ // monorepo subpackage where `configRoot != gitRoot`, anchoring layer
136
+ // 4 at `gitRoot` would look for sediment in the wrong directory.
137
+ const configAnchor = path.resolve(configRoot);
138
+ let cachedGitAnchor;
139
+ const getGitAnchor = () => {
140
+ if (cachedGitAnchor !== undefined)
141
+ return cachedGitAnchor;
142
+ let gitRoot;
143
+ if (options.gitRoot !== undefined) {
144
+ gitRoot = options.gitRoot;
145
+ }
146
+ else {
147
+ try {
148
+ gitRoot = resolveGitRoot(configRoot);
149
+ // totem-context: intentional fall-through — resolveGitRoot throws on permission errors / corrupted index; fall back to configAnchor so the precedence chain still completes.
150
+ }
151
+ catch {
152
+ gitRoot = null;
153
+ }
154
+ }
155
+ cachedGitAnchor = gitRoot ?? configAnchor;
156
+ return cachedGitAnchor;
157
+ };
158
+ // Layer 1 — env var. Validate substrate shape; fall through on shape miss.
159
+ const envValue = readEnvValue(env);
160
+ if (envValue !== undefined) {
161
+ const candidate = resolveValue(envValue, getGitAnchor());
162
+ if (validateSubstrateShape(candidate)) {
163
+ return substrateResult(candidate);
164
+ }
165
+ }
166
+ // Layer 2 — config field. The `typeof string` guard is load-bearing:
167
+ // a JS caller (or unsafe cast) could pass a non-string `substratePath`
168
+ // that would crash `.trim()` with `TypeError`. Treating non-string as
169
+ // unset preserves the contract. Mirrors `resolveStrategyRoot`.
170
+ if (typeof config?.substratePath === 'string' && config.substratePath.trim().length > 0) {
171
+ const candidate = resolveValue(config.substratePath, getGitAnchor());
172
+ if (validateSubstrateShape(candidate)) {
173
+ return substrateResult(candidate);
174
+ }
175
+ }
176
+ // Layer 3 — sibling-walk. Walk up to SIBLING_WALK_MAX_DEPTH levels
177
+ // from `gitAnchor` looking for `<parent>/totem-substrate/`. Cap
178
+ // protects against pathological resolution when the anchor is
179
+ // accidentally a deep subpath.
180
+ let walkAnchor = getGitAnchor();
181
+ for (let i = 0; i < SIBLING_WALK_MAX_DEPTH; i++) {
182
+ const parent = path.dirname(walkAnchor);
183
+ // path.dirname returns the path itself at filesystem root; break to
184
+ // avoid an infinite loop if SIBLING_WALK_MAX_DEPTH outruns the
185
+ // tree depth.
186
+ if (parent === walkAnchor)
187
+ break;
188
+ const candidate = path.normalize(path.join(parent, SIBLING_DIRNAME));
189
+ if (validateSubstrateShape(candidate)) {
190
+ return substrateResult(candidate);
191
+ }
192
+ walkAnchor = parent;
193
+ }
194
+ // Layer 4 — repo-local sediment, anchored at `configAnchor` (NOT
195
+ // `gitAnchor`). Per-directory presence: either `.handoff/` alone OR
196
+ // `.journal/` alone is a valid partial sediment result; consumer
197
+ // reads non-null only. The Phase B cutover left both as
198
+ // `.gitkeep`-only frozen markers in the product repos, so
199
+ // existence-of-directory (not existence-of-content) is the right
200
+ // gate here — matches `isDirectory`'s semantic on layers 1-3.
201
+ const localHandoff = path.normalize(path.join(configAnchor, '.handoff'));
202
+ const localJournal = path.normalize(path.join(configAnchor, '.journal'));
203
+ const handoffExists = isDirectory(localHandoff);
204
+ const journalExists = isDirectory(localJournal);
205
+ // totem-context: boolean-or for presence check; nullish-coalescing rule is targeted at numeric-metric defaults, not booleans.
206
+ if (handoffExists || journalExists) {
207
+ return {
208
+ handoffRoot: handoffExists ? localHandoff : null,
209
+ journalRoot: journalExists ? localJournal : null,
210
+ source: 'repo-local',
211
+ };
212
+ }
213
+ // All four layers failed — graceful degradation per ADR-090.
214
+ return { handoffRoot: null, journalRoot: null, source: 'none' };
215
+ }
216
+ //# sourceMappingURL=substrate-resolver.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"substrate-resolver.js","sourceRoot":"","sources":["../src/substrate-resolver.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Tests for `resolveSubstratePaths` (mmnto-ai/totem#1820, ADR-100 Phase C).
3
+ *
4
+ * Filesystem-driven; tests construct real temp directories for each
5
+ * precedence layer (env / config / sibling-walk / repo-local sediment)
6
+ * and pass `env` / `config` via the option seams to avoid touching
7
+ * `process.env`. Per-test cleanup wipes the tmp tree so a re-run starts
8
+ * from a clean state.
9
+ */
10
+ export {};
11
+ //# sourceMappingURL=substrate-resolver.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"substrate-resolver.test.d.ts","sourceRoot":"","sources":["../src/substrate-resolver.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG"}
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Tests for `resolveSubstratePaths` (mmnto-ai/totem#1820, ADR-100 Phase C).
3
+ *
4
+ * Filesystem-driven; tests construct real temp directories for each
5
+ * precedence layer (env / config / sibling-walk / repo-local sediment)
6
+ * and pass `env` / `config` via the option seams to avoid touching
7
+ * `process.env`. Per-test cleanup wipes the tmp tree so a re-run starts
8
+ * from a clean state.
9
+ */
10
+ import * as fs from 'node:fs';
11
+ import * as os from 'node:os';
12
+ import * as path from 'node:path';
13
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
14
+ import { resolveSubstratePaths, } from './substrate-resolver.js';
15
+ import { cleanTmpDir } from './test-utils.js';
16
+ const ENV_KEYS = ['TOTEM_SUBSTRATE_PATH'];
17
+ let tmpRoot;
18
+ let configRoot;
19
+ let parent;
20
+ function emptyEnv() {
21
+ return {};
22
+ }
23
+ function mkDir(p) {
24
+ fs.mkdirSync(p, { recursive: true });
25
+ return p;
26
+ }
27
+ /**
28
+ * Build a fully-shaped substrate clone at `dir` — git metadata subdir +
29
+ * handoff + journal subdirs all present. Returns the absolute dir path.
30
+ */
31
+ function mkSubstrate(dir) {
32
+ mkDir(dir);
33
+ // totem-context: building a fake substrate fixture for shape-gate test; not a gitRoot probe — see substrate-resolver.ts validateSubstrateShape JSDoc.
34
+ mkDir(path.join(dir, '.git'));
35
+ mkDir(path.join(dir, '.handoff'));
36
+ mkDir(path.join(dir, '.journal'));
37
+ return dir;
38
+ }
39
+ beforeEach(() => {
40
+ // Layout:
41
+ // <tmpRoot>/
42
+ // parent/
43
+ // repo/ ← configRoot
44
+ // totem-substrate/ ← sibling target (per-test)
45
+ // elsewhere/ ← env / config target (per-test)
46
+ // totem-context: test fixture only; agents do not consume this temp dir.
47
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-substrate-resolver-'));
48
+ parent = mkDir(path.join(tmpRoot, 'parent'));
49
+ configRoot = mkDir(path.join(parent, 'repo'));
50
+ });
51
+ afterEach(() => {
52
+ cleanTmpDir(tmpRoot);
53
+ for (const k of ENV_KEYS)
54
+ delete process.env[k];
55
+ });
56
+ // ─── Task 1 — substrate shape validation ───────────────────────────────────
57
+ describe('resolveSubstratePaths shape validation', () => {
58
+ // TEST DIRECTIVE (Task 1): a valid directory missing one of the three
59
+ // expected subdirs MUST be rejected by the internal validator and the
60
+ // resolver MUST fall through to the next layer.
61
+ it('rejects empty directory without required substrate shape', () => {
62
+ const incomplete = mkDir(path.join(tmpRoot, 'incomplete-substrate'));
63
+ // totem-context: fake substrate fixture; shape gate, not gitRoot probe.
64
+ mkDir(path.join(incomplete, '.git'));
65
+ mkDir(path.join(incomplete, '.handoff'));
66
+ // .journal intentionally missing → shape invalid
67
+ const result = resolveSubstratePaths(configRoot, {
68
+ env: { TOTEM_SUBSTRATE_PATH: incomplete },
69
+ });
70
+ // Env layer rejects on shape miss; no other layers populated → 'none'.
71
+ expect(result).toEqual({
72
+ handoffRoot: null,
73
+ journalRoot: null,
74
+ source: 'none',
75
+ });
76
+ });
77
+ it('rejects directory missing the git subdir (sibling-walk layer)', () => {
78
+ // No git metadata subdir — looks like an empty `totem-substrate` dir
79
+ // created by accident, not a real clone.
80
+ const stale = mkDir(path.join(parent, 'totem-substrate'));
81
+ mkDir(path.join(stale, '.handoff'));
82
+ mkDir(path.join(stale, '.journal'));
83
+ const result = resolveSubstratePaths(configRoot, { env: emptyEnv() });
84
+ // Sibling-walk layer rejects on shape miss; no other layers populated → 'none'.
85
+ expect(result.source).toBe('none');
86
+ });
87
+ it('accepts a fully-shaped substrate (env layer)', () => {
88
+ const real = mkSubstrate(path.join(tmpRoot, 'elsewhere'));
89
+ const result = resolveSubstratePaths(configRoot, {
90
+ env: { TOTEM_SUBSTRATE_PATH: real },
91
+ });
92
+ expect(result).toEqual({
93
+ handoffRoot: path.normalize(path.join(real, '.handoff')),
94
+ journalRoot: path.normalize(path.join(real, '.journal')),
95
+ source: 'substrate',
96
+ });
97
+ });
98
+ });
99
+ // ─── Task 2 — precedence cascade ───────────────────────────────────────────
100
+ describe('resolveSubstratePaths precedence', () => {
101
+ it('returns substrate source when TOTEM_SUBSTRATE_PATH points to a valid clone', () => {
102
+ const target = mkSubstrate(path.join(tmpRoot, 'env-target'));
103
+ const result = resolveSubstratePaths(configRoot, {
104
+ env: { TOTEM_SUBSTRATE_PATH: target },
105
+ });
106
+ expect(result.source).toBe('substrate');
107
+ expect(result.handoffRoot).toBe(path.normalize(path.join(target, '.handoff')));
108
+ });
109
+ it('returns substrate source when env is unset and config.substratePath is set', () => {
110
+ const target = mkSubstrate(path.join(tmpRoot, 'config-target'));
111
+ const result = resolveSubstratePaths(configRoot, {
112
+ env: emptyEnv(),
113
+ config: { substratePath: target },
114
+ });
115
+ expect(result.source).toBe('substrate');
116
+ expect(result.handoffRoot).toBe(path.normalize(path.join(target, '.handoff')));
117
+ });
118
+ it('prefers env over config when both are set', () => {
119
+ const envTarget = mkSubstrate(path.join(tmpRoot, 'env-pref'));
120
+ const configTarget = mkSubstrate(path.join(tmpRoot, 'config-pref'));
121
+ const result = resolveSubstratePaths(configRoot, {
122
+ env: { TOTEM_SUBSTRATE_PATH: envTarget },
123
+ config: { substratePath: configTarget },
124
+ });
125
+ expect(result.handoffRoot).toBe(path.normalize(path.join(envTarget, '.handoff')));
126
+ });
127
+ it('returns substrate source when env+config unset and ../totem-substrate exists', () => {
128
+ const sibling = mkSubstrate(path.join(parent, 'totem-substrate'));
129
+ const result = resolveSubstratePaths(configRoot, { env: emptyEnv() });
130
+ expect(result).toEqual({
131
+ handoffRoot: path.normalize(path.join(sibling, '.handoff')),
132
+ journalRoot: path.normalize(path.join(sibling, '.journal')),
133
+ source: 'substrate',
134
+ });
135
+ });
136
+ it('prefers config over sibling-walk when both resolve', () => {
137
+ mkSubstrate(path.join(parent, 'totem-substrate'));
138
+ const configTarget = mkSubstrate(path.join(tmpRoot, 'config-pref'));
139
+ const result = resolveSubstratePaths(configRoot, {
140
+ env: emptyEnv(),
141
+ config: { substratePath: configTarget },
142
+ });
143
+ expect(result.handoffRoot).toBe(path.normalize(path.join(configTarget, '.handoff')));
144
+ });
145
+ it('falls through env when path is missing — config takes over', () => {
146
+ const configTarget = mkSubstrate(path.join(tmpRoot, 'config-fallback'));
147
+ const result = resolveSubstratePaths(configRoot, {
148
+ env: { TOTEM_SUBSTRATE_PATH: path.join(tmpRoot, 'does-not-exist') },
149
+ config: { substratePath: configTarget },
150
+ });
151
+ expect(result.source).toBe('substrate');
152
+ expect(result.handoffRoot).toBe(path.normalize(path.join(configTarget, '.handoff')));
153
+ });
154
+ it('treats whitespace-only env value as unset', () => {
155
+ const sibling = mkSubstrate(path.join(parent, 'totem-substrate'));
156
+ const result = resolveSubstratePaths(configRoot, {
157
+ env: { TOTEM_SUBSTRATE_PATH: ' ' },
158
+ });
159
+ expect(result.handoffRoot).toBe(path.normalize(path.join(sibling, '.handoff')));
160
+ });
161
+ it('treats whitespace-only config value as unset', () => {
162
+ const sibling = mkSubstrate(path.join(parent, 'totem-substrate'));
163
+ const result = resolveSubstratePaths(configRoot, {
164
+ env: emptyEnv(),
165
+ config: { substratePath: ' ' },
166
+ });
167
+ expect(result.handoffRoot).toBe(path.normalize(path.join(sibling, '.handoff')));
168
+ });
169
+ it('treats non-string config.substratePath as unset (R5 — runtime type guard)', () => {
170
+ const sibling = mkSubstrate(path.join(parent, 'totem-substrate'));
171
+ const result = resolveSubstratePaths(configRoot, {
172
+ env: emptyEnv(),
173
+ // totem-context: cast is the SUBJECT of the test — proves the resolver's runtime guard catches non-string inputs that bypass TS type-checking.
174
+ config: { substratePath: 42 },
175
+ });
176
+ expect(result.source).toBe('substrate');
177
+ expect(result.handoffRoot).toBe(path.normalize(path.join(sibling, '.handoff')));
178
+ });
179
+ });
180
+ // ─── Sibling-walk depth cap ────────────────────────────────────────────────
181
+ describe('resolveSubstratePaths sibling-walk', () => {
182
+ // TEST DIRECTIVE (Task 2): a configRoot deeper than 3 levels from the
183
+ // substrate sibling MUST NOT find it via walk; falls through.
184
+ it('bounds sibling discovery to maximum 3 directory levels', () => {
185
+ // Layout: <tmpRoot>/totem-substrate/ + <tmpRoot>/a/b/c/d/e/repo
186
+ // Walk from repo: depth 1 = e, 2 = d, 3 = c. Looking for
187
+ // c/totem-substrate, d/totem-substrate, e/totem-substrate at each
188
+ // level. None is at <tmpRoot>/, so depth-cap stops the walk
189
+ // before finding the substrate at <tmpRoot>/totem-substrate.
190
+ mkSubstrate(path.join(tmpRoot, 'totem-substrate'));
191
+ const deepRepo = mkDir(path.join(tmpRoot, 'a', 'b', 'c', 'd', 'e', 'repo'));
192
+ const result = resolveSubstratePaths(deepRepo, { env: emptyEnv() });
193
+ // Walk doesn't find substrate within 3 levels → falls through to
194
+ // repo-local sediment (which is also absent) → 'none'.
195
+ expect(result.source).toBe('none');
196
+ });
197
+ it('finds sibling within 3 levels — depth 1 (parent)', () => {
198
+ const sibling = mkSubstrate(path.join(parent, 'totem-substrate'));
199
+ const result = resolveSubstratePaths(configRoot, { env: emptyEnv() });
200
+ expect(result.handoffRoot).toBe(path.normalize(path.join(sibling, '.handoff')));
201
+ });
202
+ it('finds sibling within 3 levels — depth 3 (grandparent of grandparent)', () => {
203
+ // Layout: <tmpRoot>/totem-substrate/ + <tmpRoot>/a/b/repo
204
+ // Walk: depth 1 = b, 2 = a, 3 = tmpRoot. Substrate at depth 3.
205
+ const sibling = mkSubstrate(path.join(tmpRoot, 'totem-substrate'));
206
+ const repo = mkDir(path.join(tmpRoot, 'a', 'b', 'repo'));
207
+ const result = resolveSubstratePaths(repo, { env: emptyEnv() });
208
+ expect(result.handoffRoot).toBe(path.normalize(path.join(sibling, '.handoff')));
209
+ });
210
+ it('does not loop infinitely when configRoot is at filesystem root', () => {
211
+ // path.dirname('/') === '/' on posix; 'C:\\' on win32. Walk loop must
212
+ // detect dirname-equals-self and break, not spin forever.
213
+ const rootishPath = path.parse(tmpRoot).root;
214
+ const result = resolveSubstratePaths(rootishPath, { env: emptyEnv() });
215
+ // No assertion on source — just proving the call returns within the
216
+ // test's effective timeout.
217
+ expect(result).toBeDefined();
218
+ });
219
+ });
220
+ // ─── Layer 4 — repo-local sediment ─────────────────────────────────────────
221
+ describe('resolveSubstratePaths repo-local sediment', () => {
222
+ it('returns repo-local source when both .handoff/ and .journal/ exist locally', () => {
223
+ mkDir(path.join(configRoot, '.handoff'));
224
+ mkDir(path.join(configRoot, '.journal'));
225
+ const result = resolveSubstratePaths(configRoot, { env: emptyEnv() });
226
+ expect(result).toEqual({
227
+ handoffRoot: path.normalize(path.join(configRoot, '.handoff')),
228
+ journalRoot: path.normalize(path.join(configRoot, '.journal')),
229
+ source: 'repo-local',
230
+ });
231
+ });
232
+ it('returns partial repo-local when only .journal/ exists', () => {
233
+ mkDir(path.join(configRoot, '.journal'));
234
+ const result = resolveSubstratePaths(configRoot, { env: emptyEnv() });
235
+ expect(result).toEqual({
236
+ handoffRoot: null,
237
+ journalRoot: path.normalize(path.join(configRoot, '.journal')),
238
+ source: 'repo-local',
239
+ });
240
+ });
241
+ it('returns partial repo-local when only .handoff/ exists', () => {
242
+ mkDir(path.join(configRoot, '.handoff'));
243
+ const result = resolveSubstratePaths(configRoot, { env: emptyEnv() });
244
+ expect(result).toEqual({
245
+ handoffRoot: path.normalize(path.join(configRoot, '.handoff')),
246
+ journalRoot: null,
247
+ source: 'repo-local',
248
+ });
249
+ });
250
+ it('anchors layer-4 sediment at configRoot, not gitRoot (CR R2 catch)', () => {
251
+ // Monorepo-subpackage scenario: configRoot is a deep subpath; gitRoot
252
+ // is the monorepo root. Sediment lives under configRoot per the
253
+ // trigger spec (`<configRoot>/.handoff/`), NOT under gitRoot.
254
+ // Pre-fix bug (CR R2): both anchors collapsed to gitRoot, so layer 4
255
+ // looked for sediment in the wrong directory.
256
+ //
257
+ // Place sediment ONLY under configRoot. If the resolver mistakenly
258
+ // anchors at gitRoot, sediment lookup fails and we fall through to
259
+ // 'none'. Correct behavior: 'repo-local' with paths under configRoot.
260
+ const subpackage = mkDir(path.join(configRoot, 'packages', 'mcp'));
261
+ mkDir(path.join(subpackage, '.handoff'));
262
+ mkDir(path.join(subpackage, '.journal'));
263
+ // gitRoot is `parent` (monorepo root); no sediment there.
264
+ const result = resolveSubstratePaths(subpackage, {
265
+ env: emptyEnv(),
266
+ gitRoot: parent, // pin gitAnchor distinct from configAnchor
267
+ });
268
+ expect(result).toEqual({
269
+ handoffRoot: path.normalize(path.join(subpackage, '.handoff')),
270
+ journalRoot: path.normalize(path.join(subpackage, '.journal')),
271
+ source: 'repo-local',
272
+ });
273
+ });
274
+ it('repo-local accepts placeholder-marker-only sediment dirs (Phase B cutover state)', () => {
275
+ // Sediment-frozen dirs in product repos retain a placeholder marker
276
+ // after Phase B markers landed. The resolver must accept these as
277
+ // valid fallback (the consumer is responsible for handling
278
+ // empty-content cases).
279
+ const handoff = mkDir(path.join(configRoot, '.handoff'));
280
+ // totem-context: writing a tracked-empty placeholder for fixture; not a hooks-manager bypass.
281
+ fs.writeFileSync(path.join(handoff, '.gitkeep'), '');
282
+ const journal = mkDir(path.join(configRoot, '.journal'));
283
+ // totem-context: writing a tracked-empty placeholder for fixture; not a hooks-manager bypass.
284
+ fs.writeFileSync(path.join(journal, '.gitkeep'), '');
285
+ const result = resolveSubstratePaths(configRoot, { env: emptyEnv() });
286
+ expect(result.source).toBe('repo-local');
287
+ expect(result.handoffRoot).toBe(path.normalize(handoff));
288
+ expect(result.journalRoot).toBe(path.normalize(journal));
289
+ });
290
+ });
291
+ // ─── ADR-090 graceful degradation ──────────────────────────────────────────
292
+ describe('resolveSubstratePaths graceful degradation', () => {
293
+ it('returns source: none when all four layers fail (ADR-090 contract)', () => {
294
+ // No env, no config, no sibling, no repo-local sediment.
295
+ const result = resolveSubstratePaths(configRoot, { env: emptyEnv() });
296
+ expect(result).toEqual({
297
+ handoffRoot: null,
298
+ journalRoot: null,
299
+ source: 'none',
300
+ });
301
+ });
302
+ it('tolerates configRoot at tmp root without crashing', () => {
303
+ // Ensure resolver tolerates a configRoot whose dirname() may not exist
304
+ // as a real ancestor (filesystem root edge cases). The body assertion
305
+ // proves the call returned cleanly under any source.
306
+ const result = resolveSubstratePaths(tmpRoot, { env: emptyEnv() });
307
+ expect(result.source).toMatch(/^(repo-local|none)$/);
308
+ });
309
+ });
310
+ // ─── Path normalization ────────────────────────────────────────────────────
311
+ describe('resolveSubstratePaths path normalization', () => {
312
+ // Mixed-separator handling is Windows-specific: on POSIX, backslash is a
313
+ // valid filename character and `/` is the only path separator, so the
314
+ // resolver-level mixed-sep behavior only matters on `process.platform ===
315
+ // 'win32'`. Coverage for `path.normalize`-driven cleanup is provided by
316
+ // the segment-collapse test below, which works on every platform.
317
+ const onWindows = process.platform === 'win32' ? it : it.skip;
318
+ onWindows('normalizes mixed separators in env value (Windows-only)', () => {
319
+ const target = mkSubstrate(path.join(tmpRoot, 'mixed-seps'));
320
+ const mixed = target.replace('\\', '/');
321
+ const result = resolveSubstratePaths(configRoot, {
322
+ env: { TOTEM_SUBSTRATE_PATH: mixed },
323
+ });
324
+ expect(result.handoffRoot).toBe(path.normalize(path.join(target, '.handoff')));
325
+ });
326
+ it('normalizes . and .. segments in env value', () => {
327
+ const target = mkSubstrate(path.join(tmpRoot, 'nested', 'real-substrate'));
328
+ const noisy = path.join(tmpRoot, 'nested', '.', 'real-substrate', '..', 'real-substrate');
329
+ const result = resolveSubstratePaths(configRoot, {
330
+ env: { TOTEM_SUBSTRATE_PATH: noisy },
331
+ });
332
+ expect(result.handoffRoot).toBe(path.normalize(path.join(target, '.handoff')));
333
+ });
334
+ it('returns absolute paths regardless of caller anchor', () => {
335
+ const sibling = mkSubstrate(path.join(parent, 'totem-substrate'));
336
+ const result = resolveSubstratePaths(configRoot, { env: emptyEnv() });
337
+ expect(path.isAbsolute(result.handoffRoot ?? '')).toBe(true);
338
+ expect(path.isAbsolute(result.journalRoot ?? '')).toBe(true);
339
+ // Sanity: the absolute path points at the sibling we built.
340
+ expect(result.handoffRoot).toBe(path.normalize(path.join(sibling, '.handoff')));
341
+ });
342
+ it('uses options.gitRoot test seam to override the natural configRoot anchor', () => {
343
+ // The lazy gitRoot probe lets monorepo subpackage callers anchor at
344
+ // the real repo root. To prove the seam overrides configRoot, we
345
+ // place substrate near the SEAM, NOT near configRoot — if the seam
346
+ // is honored, the walk finds substrate; if the seam is ignored and
347
+ // configRoot becomes the anchor, the walk fails.
348
+ const sibling = mkSubstrate(path.join(parent, 'totem-substrate'));
349
+ // Build an unrelated path whose dirname has NO substrate near it.
350
+ const unrelated = mkDir(path.join(tmpRoot, 'unrelated', 'somewhere'));
351
+ const result = resolveSubstratePaths(unrelated, {
352
+ env: emptyEnv(),
353
+ // Seam pins anchor at `configRoot` (= parent/repo); walk from
354
+ // there finds substrate at `parent/totem-substrate`.
355
+ gitRoot: configRoot,
356
+ });
357
+ expect(result.handoffRoot).toBe(path.normalize(path.join(sibling, '.handoff')));
358
+ });
359
+ it('falls back to absolutized configRoot when gitRoot is explicitly null', () => {
360
+ // `gitRoot: null` simulates the "configRoot is not in a git repo"
361
+ // case — production callers in fresh clones or non-git dirs hit this
362
+ // path. The resolver must absolutize configRoot (path.resolve) so the
363
+ // walk doesn't break on a relative anchor.
364
+ const sibling = mkSubstrate(path.join(parent, 'totem-substrate'));
365
+ const result = resolveSubstratePaths(configRoot, {
366
+ env: emptyEnv(),
367
+ gitRoot: null,
368
+ });
369
+ expect(result.handoffRoot).toBe(path.normalize(path.join(sibling, '.handoff')));
370
+ });
371
+ it('absolutizes a relative configRoot before walking (review-1 catch)', () => {
372
+ // Pre-fix bug: `path.dirname('.') === '.'`, so a relative anchor
373
+ // broke the sibling-walk loop on the first iteration. The resolver
374
+ // must absolutize via `path.resolve` so the walk reaches real ancestors.
375
+ const prevCwd = process.cwd();
376
+ try {
377
+ // chdir into configRoot so '.' resolves to it. The sibling at
378
+ // `parent/totem-substrate/` becomes reachable via the walk's
379
+ // first iteration once `path.resolve('.')` produces an absolute path.
380
+ const sibling = mkSubstrate(path.join(parent, 'totem-substrate'));
381
+ process.chdir(configRoot);
382
+ const result = resolveSubstratePaths('.', { env: emptyEnv() });
383
+ // Path equality on the absolutized resolution. Use realpathSync so
384
+ // macOS `/tmp` ↔ `/private/tmp` symlink discrepancies don't break
385
+ // assertions on `process.chdir`-resolved paths.
386
+ const expected = fs.realpathSync.native(path.join(sibling, '.handoff'));
387
+ const actual = result.handoffRoot ? fs.realpathSync.native(result.handoffRoot) : null;
388
+ expect(actual).toBe(expected);
389
+ expect(path.isAbsolute(result.handoffRoot ?? '')).toBe(true);
390
+ }
391
+ finally {
392
+ process.chdir(prevCwd);
393
+ }
394
+ });
395
+ });
396
+ // ─── Type compile-checks (no runtime behavior) ─────────────────────────────
397
+ describe('resolveSubstratePaths type contract', () => {
398
+ it('accepts SubstrateResolverOptions with all optional fields omitted', () => {
399
+ // Compile-time check — these calls just need to typecheck and not throw.
400
+ const opts = {};
401
+ const cfg = {};
402
+ expect(() => resolveSubstratePaths(configRoot, opts)).not.toThrow();
403
+ expect(() => resolveSubstratePaths(configRoot, { config: cfg })).not.toThrow();
404
+ });
405
+ });
406
+ //# sourceMappingURL=substrate-resolver.test.js.map