@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.
- package/dist/compile-manifest.d.ts +2 -2
- package/dist/config-schema.d.ts +29 -12
- package/dist/config-schema.d.ts.map +1 -1
- package/dist/config-schema.js +16 -0
- package/dist/config-schema.js.map +1 -1
- package/dist/config-schema.test.js +29 -0
- package/dist/config-schema.test.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/pack-discovery.d.ts +26 -0
- package/dist/pack-discovery.d.ts.map +1 -1
- package/dist/pack-discovery.js +22 -1
- package/dist/pack-discovery.js.map +1 -1
- package/dist/pack-manifest-writer.d.ts +5 -0
- package/dist/pack-manifest-writer.d.ts.map +1 -1
- package/dist/pack-manifest-writer.js +12 -1
- package/dist/pack-manifest-writer.js.map +1 -1
- package/dist/pack-manifest-writer.test.js +41 -0
- package/dist/pack-manifest-writer.test.js.map +1 -1
- package/dist/rule-engine.d.ts.map +1 -1
- package/dist/rule-engine.js +17 -0
- package/dist/rule-engine.js.map +1 -1
- package/dist/rule-engine.test.js +101 -1
- package/dist/rule-engine.test.js.map +1 -1
- package/dist/stale-manifest.d.ts +72 -0
- package/dist/stale-manifest.d.ts.map +1 -0
- package/dist/stale-manifest.js +119 -0
- package/dist/stale-manifest.js.map +1 -0
- package/dist/stale-manifest.test.d.ts +15 -0
- package/dist/stale-manifest.test.d.ts.map +1 -0
- package/dist/stale-manifest.test.js +164 -0
- package/dist/stale-manifest.test.js.map +1 -0
- package/dist/store/lance-schema.d.ts +2 -2
- package/dist/substrate-resolver.d.ts +76 -0
- package/dist/substrate-resolver.d.ts.map +1 -0
- package/dist/substrate-resolver.js +216 -0
- package/dist/substrate-resolver.js.map +1 -0
- package/dist/substrate-resolver.test.d.ts +11 -0
- package/dist/substrate-resolver.test.d.ts.map +1 -0
- package/dist/substrate-resolver.test.js +406 -0
- package/dist/substrate-resolver.test.js.map +1 -0
- package/dist/types.d.ts +2 -2
- 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
|