mellos-mapping 0.22.1 → 0.23.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.
@@ -0,0 +1,27 @@
1
+ export function sourceError(raw) {
2
+ if (!Array.isArray(raw) || raw.length > 100)
3
+ return 'sources must be an array of at most 100 file references';
4
+ for (const item of raw) {
5
+ if (!item || typeof item !== 'object' || Array.isArray(item))
6
+ return 'source must be an object';
7
+ const s = item;
8
+ if (Object.keys(s).some(k => k !== 'path' && k !== 'sha256'))
9
+ return 'unknown source field';
10
+ if (typeof s.path !== 'string' || s.path.length > 1024 || !s.path || /[\u0000-\u001f\u007f-\u009f\\:]/.test(s.path) || s.path.startsWith('/') || s.path.split('/').some(p => !p || p === '.' || p === '..'))
11
+ return 'source path must be relative to the project, with forward slashes and no traversal';
12
+ if (s.sha256 !== undefined && (typeof s.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(s.sha256)))
13
+ return 'source sha256 must be a lowercase SHA256 hash';
14
+ }
15
+ return undefined;
16
+ }
17
+ export function contextError(raw) {
18
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
19
+ return 'context must be an object';
20
+ for (const [key, value] of Object.entries(raw)) {
21
+ if (key !== 'summary' && key !== 'next')
22
+ return 'unknown context field';
23
+ if (typeof value !== 'string' || value.length > 2000 || /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/.test(value))
24
+ return 'context fields must be text of at most 2000 characters';
25
+ }
26
+ return undefined;
27
+ }
@@ -1,9 +1,15 @@
1
+ import { contextError, sourceError } from './context.js';
1
2
  // Explicit ranges also work in the JSON Schema published by the MCP adapter.
2
3
  export const NO_CONTROLS = /^[^\u0000-\u001f\u007f-\u009f]*$/;
3
4
  export const NO_CONTROLS_TEXT = 'one line of text; control characters (ESC, newline, tab) are not allowed';
4
5
  export const NO_CONTROLS_BUT_BREAKS = /^[^\u0000-\u0008\u000b-\u001f\u007f-\u009f]*$/;
5
6
  export const NO_CONTROLS_BUT_BREAKS_TEXT = 'text with optional newlines (\\n) and tabs; other control characters (ESC, BEL, CR) are not allowed';
6
7
  export function mapTextError(map) {
8
+ if (map.context !== undefined) {
9
+ const error = contextError(map.context);
10
+ if (error)
11
+ return error;
12
+ }
7
13
  const check = (field, value, multiline = false) => value === undefined || (multiline ? NO_CONTROLS_BUT_BREAKS : NO_CONTROLS).test(value)
8
14
  ? undefined : `${field}: ${multiline ? NO_CONTROLS_BUT_BREAKS_TEXT : NO_CONTROLS_TEXT}`;
9
15
  let error = check('title', map.title);
@@ -22,6 +28,11 @@ export function mapTextError(map) {
22
28
  }
23
29
  }
24
30
  for (const [i, node] of map.nodes.entries()) {
31
+ if (node.sources !== undefined) {
32
+ const error = sourceError(node.sources);
33
+ if (error)
34
+ return `nodes[${i}]: ${error}`;
35
+ }
25
36
  for (const name of ['label', 'evidence', 'detail']) {
26
37
  // Version-1 files and document exports also support multiline evidence.
27
38
  // MCP keeps its narrower one-line evidence input for concise updates.
@@ -171,7 +171,9 @@ export interface MapLane {
171
171
  readonly label: string;
172
172
  }
173
173
  /** A unit of work living in exactly one band. */
174
+ export type { SourceRef, MapContext } from './context.js';
174
175
  export interface MapNode {
176
+ readonly sources?: readonly import('./context.js').SourceRef[];
175
177
  readonly id: NodeId;
176
178
  readonly label: string;
177
179
  readonly layer: LayerId;
@@ -198,6 +200,7 @@ export interface DepEdge {
198
200
  }
199
201
  /** The whole map. A plain immutable value — operations return new maps. */
200
202
  export interface MellosMap {
203
+ readonly context?: import('./context.js').MapContext;
201
204
  readonly title?: string;
202
205
  /** Presentation intent; absent means 'dev' (the progress ledger). */
203
206
  readonly kind?: MapKind;
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import { declareGroup, declareLane, declareLayer, declareNode, linkNodes, setKind, setTitle, updateNode } from '../domain/ops.js';
24
24
  import { mapTextError } from '../domain/text.js';
25
+ import { sourceError, contextError } from '../domain/context.js';
25
26
  import { EMPTY_MAP, ID_RULE, ID_RULE_TEXT, describeMapError, err, makeGroupId, makeLaneId, makeLayerId, makeMapKind, makeNodeId, makeNodeKind, makeNodeStatus, makeRank, makeSubmapRef, ok, } from '../domain/types.js';
26
27
  /** On-disk format version. Bump only with a documented migration. */
27
28
  export const STATE_FILE_VERSION = 1;
@@ -99,8 +100,8 @@ function optionalString(rec, key, where, path) {
99
100
  export function parseMap(raw, path) {
100
101
  if (!isRecord(raw))
101
102
  return err({ kind: 'bad-shape', path, detail: 'root is not an object' });
102
- if (raw['version'] !== STATE_FILE_VERSION) {
103
- return err({ kind: 'bad-shape', path, detail: `version is ${String(raw['version'])}, expected ${STATE_FILE_VERSION}` });
103
+ if (raw['version'] !== STATE_FILE_VERSION && raw['version'] !== 2) {
104
+ return err({ kind: 'bad-shape', path, detail: `version is ${String(raw['version'])}, expected ${STATE_FILE_VERSION} or 2` });
104
105
  }
105
106
  const layers = arrayField(raw, 'layers', path, 'required');
106
107
  if (!layers.ok)
@@ -118,6 +119,12 @@ export function parseMap(raw, path) {
118
119
  if (!groups.ok)
119
120
  return groups;
120
121
  let map = EMPTY_MAP;
122
+ if (raw['context'] !== undefined) {
123
+ const error = contextError(raw['context']);
124
+ if (error)
125
+ return err({ kind: 'bad-shape', path, detail: error });
126
+ map = { ...map, context: raw['context'] };
127
+ }
121
128
  const title = optionalString(raw, 'title', 'map', path);
122
129
  if (!title.ok)
123
130
  return title;
@@ -292,6 +299,12 @@ export function parseMap(raw, path) {
292
299
  return err({ kind: 'invariant-violation', path, violation: updated.error });
293
300
  map = updated.value;
294
301
  }
302
+ if (rawNode['sources'] !== undefined) {
303
+ const error = sourceError(rawNode['sources']);
304
+ if (error)
305
+ return err({ kind: 'bad-shape', path, detail: `${where}: ${error}` });
306
+ map = { ...map, nodes: map.nodes.map(n => n.id === id.value ? { ...n, sources: rawNode['sources'] } : n) };
307
+ }
295
308
  }
296
309
  for (const [i, rawEdge] of edges.value.entries()) {
297
310
  const where = `edges[${i}]`;
@@ -323,7 +336,9 @@ export function parseMap(raw, path) {
323
336
  /** Serialize a map into the on-disk shape. Inverse of parseMap for valid maps (F1). */
324
337
  export function serializeMap(map) {
325
338
  const body = {
326
- version: STATE_FILE_VERSION,
339
+ // Older runtimes must refuse maps with provenance rather than silently erasing it.
340
+ version: map.context !== undefined || map.nodes.some(n => n.sources !== undefined) ? 2 : STATE_FILE_VERSION,
341
+ ...(map.context !== undefined ? { context: map.context } : {}),
327
342
  ...(map.title !== undefined ? { title: map.title } : {}),
328
343
  ...(map.kind !== undefined ? { kind: map.kind } : {}),
329
344
  layers: map.layers,
@@ -0,0 +1,2 @@
1
+ /** Nearest existing store or Git root; never cross a nested repository/worktree boundary. */
2
+ export declare function resolveProjectDirectory(cwd: string, stopAt?: readonly string[]): string;
@@ -0,0 +1,29 @@
1
+ import { existsSync, realpathSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ /** Nearest existing store or Git root; never cross a nested repository/worktree boundary. */
5
+ export function resolveProjectDirectory(cwd, stopAt = [homedir(), tmpdir()]) {
6
+ let start = resolve(cwd);
7
+ try {
8
+ start = realpathSync(start);
9
+ }
10
+ catch { /* Explicit test/manual paths may not exist yet. */ }
11
+ let dir = start;
12
+ const boundaries = new Set(stopAt.map(path => { try {
13
+ return realpathSync(path);
14
+ }
15
+ catch {
16
+ return resolve(path);
17
+ } }));
18
+ while (true) {
19
+ // A user's global .mellos directory is not an ancestor project's store.
20
+ if (dir !== start && boundaries.has(dir))
21
+ return start;
22
+ if (existsSync(join(dir, '.git')) || existsSync(join(dir, '.mellos', 'map.json')) || existsSync(join(dir, '.mellos', 'pages')))
23
+ return dir;
24
+ const parent = dirname(dir);
25
+ if (parent === dir)
26
+ return start;
27
+ dir = parent;
28
+ }
29
+ }
@@ -16,10 +16,13 @@
16
16
  * The concurrency model P2 buys, stated plainly:
17
17
  * - Several writers may target one project at once. Each save is atomic and
18
18
  * lands whole, so a reader never sees half a map — but there is NO
19
- * lost-update protection: two saves of the same page race, and the last
19
+ * lost-update protection in this low-level save API: two saves race, and the last
20
20
  * rename wins, silently discarding what the other writer computed from an
21
21
  * older read. Pages are the isolation unit (one effort = one page); two
22
22
  * sessions that must not clobber each other belong on two pages.
23
+ * Current MCP/viewer writers additionally use transaction.ts to lock the
24
+ * complete read/modify/write operation, with optional revision checks.
25
+ * Direct library saves and older processes do not acquire that lock.
23
26
  * - The temp file carries the writer's pid and a random suffix, so
24
27
  * concurrent writers never share one and never install each other's
25
28
  * half-written content.
@@ -47,3 +50,5 @@ export { VIEWERS_DIR_NAME, VIEWER_FILE_VERSION, VIEWER_HEARTBEAT_MS, VIEWER_STAL
47
50
  export { CONFIG_FILE_NAME, CONFIG_FILE_VERSION, configFilePath, userConfigFilePath, MAPPING_POLICIES, type MappingPolicy, type InvalidPolicy, makeMappingPolicy, describeMappingPolicy, loadMappingPolicy, saveMappingPolicy, POLICY_SCOPES, type PolicyScope, type MappingPolicyScopes, effectiveMappingPolicy } from './policy.js';
48
51
  export { LEGACY_STATE_FILE_RELATIVE_PATH, migrateLegacyStore } from './migration.js';
49
52
  export { loadMapFile, saveMapFile } from './maps.js';
53
+ export { withStoreLock, revisionOf, assertRevision, LedgerError } from './transaction.js';
54
+ export { resolveProjectDirectory } from './project.js';
@@ -16,10 +16,13 @@
16
16
  * The concurrency model P2 buys, stated plainly:
17
17
  * - Several writers may target one project at once. Each save is atomic and
18
18
  * lands whole, so a reader never sees half a map — but there is NO
19
- * lost-update protection: two saves of the same page race, and the last
19
+ * lost-update protection in this low-level save API: two saves race, and the last
20
20
  * rename wins, silently discarding what the other writer computed from an
21
21
  * older read. Pages are the isolation unit (one effort = one page); two
22
22
  * sessions that must not clobber each other belong on two pages.
23
+ * Current MCP/viewer writers additionally use transaction.ts to lock the
24
+ * complete read/modify/write operation, with optional revision checks.
25
+ * Direct library saves and older processes do not acquire that lock.
23
26
  * - The temp file carries the writer's pid and a random suffix, so
24
27
  * concurrent writers never share one and never install each other's
25
28
  * half-written content.
@@ -48,3 +51,5 @@ export { VIEWERS_DIR_NAME, VIEWER_FILE_VERSION, VIEWER_HEARTBEAT_MS, VIEWER_STAL
48
51
  export { CONFIG_FILE_NAME, CONFIG_FILE_VERSION, configFilePath, userConfigFilePath, MAPPING_POLICIES, makeMappingPolicy, describeMappingPolicy, loadMappingPolicy, saveMappingPolicy, POLICY_SCOPES, effectiveMappingPolicy } from './policy.js';
49
52
  export { LEGACY_STATE_FILE_RELATIVE_PATH, migrateLegacyStore } from './migration.js';
50
53
  export { loadMapFile, saveMapFile } from './maps.js';
54
+ export { withStoreLock, revisionOf, assertRevision, LedgerError } from './transaction.js';
55
+ export { resolveProjectDirectory } from './project.js';
@@ -0,0 +1,12 @@
1
+ import type { MellosMap } from '../domain/types.js';
2
+ export declare class LedgerError extends Error {
3
+ readonly code: string;
4
+ readonly details: Record<string, unknown>;
5
+ constructor(code: string, message: string, details?: Record<string, unknown>);
6
+ }
7
+ export declare const revisionOf: (map: MellosMap) => string;
8
+ export declare function assertRevision(actual: string, expected?: string): void;
9
+ /** A named page and the default page share the same project lock. */
10
+ export declare function storeDirectory(file: string): string;
11
+ /** No timed polling: contention is an explicit retryable BUSY result. Never steal a live lock. */
12
+ export declare function withStoreLock<T>(file: string, action: () => T): T;
@@ -0,0 +1,91 @@
1
+ /** Cooperative, cross-process transactions for MCP and HTTP writers. */
2
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { randomUUID, createHash } from 'node:crypto';
5
+ import { serializeMap } from './format.js';
6
+ export class LedgerError extends Error {
7
+ code;
8
+ details;
9
+ constructor(code, message, details = {}) {
10
+ super(message);
11
+ this.code = code;
12
+ this.details = details;
13
+ }
14
+ }
15
+ export const revisionOf = (map) => createHash('sha256').update(serializeMap(map)).digest('hex');
16
+ export function assertRevision(actual, expected) {
17
+ if (expected !== undefined && expected !== actual)
18
+ throw new LedgerError('CONFLICT', 'Map changed; read the current revision before retrying.', { expectedRevision: expected, actualRevision: actual });
19
+ }
20
+ /** A named page and the default page share the same project lock. */
21
+ export function storeDirectory(file) {
22
+ const dir = dirname(file);
23
+ return dir.endsWith('/pages') || dir.endsWith('\\pages') ? dirname(dir) : dir;
24
+ }
25
+ function deadOwner(lock) {
26
+ try {
27
+ const owner = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8'));
28
+ if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0)
29
+ return false;
30
+ try {
31
+ process.kill(owner.pid, 0);
32
+ return false;
33
+ }
34
+ catch (e) {
35
+ return e.code === 'ESRCH';
36
+ }
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ /** No timed polling: contention is an explicit retryable BUSY result. Never steal a live lock. */
43
+ export function withStoreLock(file, action) {
44
+ const dir = storeDirectory(file);
45
+ mkdirSync(dir, { recursive: true });
46
+ const lock = join(dir, '.write-lock');
47
+ const owner = join(lock, 'owner.json');
48
+ const token = randomUUID();
49
+ try {
50
+ mkdirSync(lock);
51
+ }
52
+ catch (error) {
53
+ if (error.code !== 'EEXIST')
54
+ throw error;
55
+ // Reapers serialize inside the old directory and recheck its owner after acquiring.
56
+ // Missing/invalid owners are deliberately not reclaimed on an age heuristic.
57
+ if (!deadOwner(lock))
58
+ throw new LedgerError('BUSY', `Another writer owns ${lock}; retry after it completes. An orphan without owner metadata needs manual inspection.`);
59
+ try {
60
+ mkdirSync(join(lock, '.reap'));
61
+ }
62
+ catch {
63
+ throw new LedgerError('BUSY', 'Another process is recovering the writer lock.');
64
+ }
65
+ if (!deadOwner(lock)) {
66
+ rmSync(join(lock, '.reap'), { recursive: true, force: true });
67
+ throw new LedgerError('BUSY', 'Writer ownership changed.');
68
+ }
69
+ rmSync(lock, { recursive: true });
70
+ try {
71
+ mkdirSync(lock);
72
+ }
73
+ catch {
74
+ throw new LedgerError('BUSY', 'Another writer acquired the recovered lock.');
75
+ }
76
+ }
77
+ let initialized = false;
78
+ try {
79
+ writeFileSync(owner, JSON.stringify({ pid: process.pid, token }), { flag: 'wx' });
80
+ initialized = true;
81
+ return action();
82
+ }
83
+ finally {
84
+ // Only remove the directory still owned by this invocation.
85
+ try {
86
+ if (!initialized || JSON.parse(readFileSync(owner, 'utf8')).token === token)
87
+ rmSync(lock, { recursive: true });
88
+ }
89
+ catch { /* A cleanup error must not misreport a successfully committed map as unsaved. */ }
90
+ }
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mellos-mapping",
3
- "version": "0.22.1",
3
+ "version": "0.23.0",
4
4
  "mcpName": "io.github.GuangminJu/mellos-mapping",
5
5
  "description": "A live layered dependency map for bottom-up development — MCP server + terminal pane. Ghost the design first, then light nodes up from the bottom as they are built and verified.",
6
6
  "type": "module",
@@ -70,6 +70,7 @@
70
70
  "lib",
71
71
  "README.zh-CN.md",
72
72
  "docs/codex.md",
73
+ "docs/map-api.md",
73
74
  "scripts/codex-register.mjs",
74
75
  "scripts/codex-cli.mjs",
75
76
  "scripts/install-mmap-command.mjs",
@@ -92,9 +93,10 @@
92
93
  "check:release": "node scripts/check-release.mjs",
93
94
  "check:package": "node scripts/check-package-surface.mjs",
94
95
  "check:codex": "node scripts/check-codex-package.mjs",
96
+ "check:reuse": "node scripts/check-reuse.mjs",
95
97
  "benchmark:render": "node scripts/benchmark-render.mjs",
96
98
  "prepack": "npm run build",
97
- "verify": "npm run typecheck && npm run test && npm run build && npm run check:package && npm run check:codex && npm run check:release"
99
+ "verify": "npm run typecheck && npm run test && npm run build && npm run check:reuse && npm run check:package && npm run check:codex && npm run check:release"
98
100
  },
99
101
  "devDependencies": {
100
102
  "@modelcontextprotocol/sdk": "^1.12.0",
@@ -42,7 +42,7 @@ if (launchedAsEntry(import.meta.url)) {
42
42
  const installed = registerServer(dirname(dirname(fileURLToPath(import.meta.url))));
43
43
  console.log(`mellos-mapping MCP registered with Codex: ${installed.nodePath} ${installed.serverPath}`);
44
44
  console.log('State files resolve to each session’s working directory (.mellos/map.json).');
45
- console.log('Start a new Codex conversation to load the skills and six mmap tools.');
45
+ console.log('Start a new Codex conversation to load the skills and eight mmap tools.');
46
46
  } catch (error) {
47
47
  console.error(error instanceof Error ? error.message : String(error));
48
48
  process.exitCode = 1;
package/scripts/mmap.mjs CHANGED
@@ -170,7 +170,7 @@ async function main() {
170
170
  console.error(parsed.error);
171
171
  process.exit(1);
172
172
  }
173
- const candidates = storeSearchPath(process.cwd());
173
+ const candidates = [store.resolveProjectDirectory(process.cwd())];
174
174
  const marker = storeMarkerOf(store.STATE_FILE_RELATIVE_PATH);
175
175
  const project = nearestProject(candidates, candidates.map((dir) => existsSync(join(dir, marker))));
176
176
  const cfg = { ...parsed.value, projectDir: project.root };