archgen-mcp 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -62,6 +62,20 @@ Every tool returns the same envelope:
62
62
  Call `archgen_help { topic: "<domain>" }` for the full briefing on any tool —
63
63
  including when to call it, every action, and its gotchas.
64
64
 
65
+ ### Fetching, not dumping
66
+
67
+ Every tool takes `limit` and `offset` (default 50 items) — the bound is the
68
+ caller's, and a bounded answer always reports what it left behind. Every
69
+ collection narrows to **one item**: `taskId`, `skillId`, `file` + `section`,
70
+ `harness`. Each answer's `meta` carries `available` (what else exists),
71
+ `more` (the call that fetches one item) and `next` (the exact call for the
72
+ next slice).
73
+
74
+ So the agent fetches what it needs instead of taking the whole set: a
75
+ reference topic is fetched **one section** at a time (`section: '*'` asks for
76
+ the whole topic deliberately), and a capability skill is fetched as its
77
+ **normative rules plus an index** — never as the file three times over.
78
+
65
79
  ### The judgment / output split
66
80
 
67
81
  The archgen **skill** carries the judgment (policy, rules, when-to-use). This
@@ -4,7 +4,7 @@ SCOPE: Read-only exploration, writing ONLY `<slug dir>/codebase-map.md`.
4
4
  DELIVERABLE: Write `<slug dir>/codebase-map.md` containing EXACTLY these four non-empty sections:
5
5
  ## Structure — Top 2 directory levels with 1-line purpose per folder
6
6
  ## Modules — Table: `module | responsibility | key entry points`
7
- ## Conventions — Observed file naming, error handling, and test placement patterns
7
+ ## Conventions — Observed file naming, error handling, and where the tests live today (the source roots and the test tree)
8
8
  ## Affected — Files/modules impacted by the feature, citing exact evidence paths
9
9
 
10
10
  NAVIGATION & VERIFICATION
@@ -9,7 +9,8 @@ STEP 2 — AUDIT JUDGMENT & CONTRACT COMPLETENESS:
9
9
  - Wave boundaries match true dependencies without artificial serialization.
10
10
  - Acceptance criteria are objectively verifiable (runnable command or observable output).
11
11
  - Edge-case coverage satisfies the scope class (`SMALL` / `MEDIUM` / `LARGE`).
12
- - Planned structure and naming obey `references/code-standards.md`.
12
+ - Planned structure and naming obey `references/code-standards.md` — including the separate source and test trees: no test tree inside the source tree, and every task that owns source owns the mirrored test path.
13
+ - The repository does not hide archgen artifacts: no `.gitignore` rule covers `.archgen/`, `.agents/skills/archgen`, `.claude/skills/archgen`, `AGENTS.md` or `CLAUDE.md`.
13
14
  - **CONTRACT-COMPLETENESS SWEEP** (evaluate every dimension; mark `N/A` only when the system genuinely lacks that surface):
14
15
  1. `functional coverage`: Every requirement maps to a task + objective acceptance check.
15
16
  2. `security & data protection`: Authn/authz flows, session/credential lifecycle, CSRF/replay defense, encryption/privacy boundaries, boundary input validation.
@@ -320,12 +320,21 @@
320
320
  "vscode": {
321
321
  "label": "VS Code / Copilot",
322
322
  "codegraphTarget": null,
323
- "detectHome": [],
323
+ "detectHome": [
324
+ ".copilot"
325
+ ],
324
326
  "detectProject": [
325
327
  ".vscode",
326
328
  ".github/copilot-instructions.md"
327
329
  ],
328
330
  "configs": {
331
+ "global": {
332
+ "path": "mcp-config.json",
333
+ "home": "copilot",
334
+ "format": "json",
335
+ "key": "mcpServers",
336
+ "entry": "stdio"
337
+ },
329
338
  "project": {
330
339
  "path": ".vscode/mcp.json",
331
340
  "format": "json",
@@ -414,6 +414,17 @@
414
414
  "env"
415
415
  ],
416
416
  "urlField": "url"
417
+ },
418
+ "global": {
419
+ "path": "~/.copilot/mcp-config.json",
420
+ "key": "mcpServers",
421
+ "note": "$COPILOT_HOME replaces this location, and VS Code checks only one of them",
422
+ "stdioFields": [
423
+ "command",
424
+ "args",
425
+ "env"
426
+ ],
427
+ "urlField": "url"
417
428
  }
418
429
  },
419
430
  "mcpCli": "copilot mcp add; palette \"MCP: Add Server\"",
@@ -62,6 +62,7 @@ Adding or omitting files outside the scaffold output is a right-sizing defect. A
62
62
  - **Runnable verification commands:** Repo-root-relative (no `../` or `--plan .`), specifying exact commands, flags, sample sizes, and thresholds.
63
63
  - **Restricted YAML subset:** Block mappings, block sequences, and single-line flow collections (`["a/**", "b.ts"]`, `{id: A}`) ONLY. **BANNED:** block scalars (`|` / `>`), duplicate keys, and multi-line flow collections.
64
64
  - **Targeted `tasks.yaml` edits:** Apply small, surgical edits or a clean full regeneration—never large multi-region patches.
65
+ - **Both trees declared:** `architecture.yaml` `structure:` lists the source tree AND its sibling test tree, and a task that owns source owns the mirrored test path (`references/code-standards.md` owns the rule).
65
66
 
66
67
  ## Agent workspace & artifact location — the two-tier rule
67
68
 
@@ -79,6 +80,27 @@ Adding or omitting files outside the scaffold output is a right-sizing defect. A
79
80
  - Source code (`src/...`) and shipped user/operator documentation (`README.md`, API docs, runbooks).
80
81
  - *Durable ADRs:* Promote repo-wide architectural decisions from `.archgen/<slug>/decisions/` to the project's canonical ADR directory (`docs/adr/`). Supersede rather than deleting/renumbering accepted ADRs.
81
82
 
83
+ Every path under `.archgen/<slug>/` is committed — the `.agent/` run state included, because it is the audit trail of how the work went (`§artifact-tracking`).
84
+
85
+ ## Artifact tracking — every archgen artifact is committed
86
+
87
+ The plan of record belongs in the repository and in the history, next to the code it plans: `git log` must show what was decided, when it changed, and what shipped.
88
+
89
+ **Committed — the whole archgen footprint:**
90
+ - `.archgen/<slug>/` — the plan of record: `tasks.yaml`, `plans/`, `docs/`, `architecture.yaml`, `decisions/`, and the `.agent/` run state.
91
+ - `.agents/skills/archgen/` — the canonical installed skill, one copy for the team.
92
+ - `.claude/skills/archgen` — the harness bridge into that store.
93
+ - `AGENTS.md` and `CLAUDE.md` — the registry hub and its pointer.
94
+ - The project MCP config (`.mcp.json`, `.cursor/mcp.json`, `opencode.json`, `.vscode/mcp.json`, …) — the wiring that gives the team the tools.
95
+
96
+ **Never committed — machine-local state:** `.codegraph/` (the local symbol index), `.archgen/.runtime/` (the legacy heartbeat), the `.archgen-version` deploy stamp.
97
+
98
+ **Rules:**
99
+ 1. **NEVER gitignore an archgen artifact.** A rule covering `.archgen/`, `.agents/`, `.claude/` or `AGENTS.md` is a defect — remove it.
100
+ 2. **Commit the plan before the first wave**, then each wave's artifact state (statuses, notes, decisions, reports, snapshot) with that wave's commits.
101
+ 3. **Fix the ignore rule before force-adding** — a forced add hides the rule from the next person; if you must force-add, say so in the completion report.
102
+ 4. **Never rewrite published history** to hide an artifact — the plan of record is append-only, like the code it plans.
103
+
82
104
  ## architecture.yaml (LARGE scope)
83
105
 
84
106
  Generated via `archgen_plan{action:'scaffold', scope:'LARGE'}`. Authoring rules for the four anti-hallucination contract blocks:
@@ -4,6 +4,8 @@
4
4
 
5
5
  `npx archgen-skill init` (or `install`) automatically wires `archgen-mcp` and CodeGraph into every detected harness. Never configure them manually. If `archgen_*` tools are missing, run `npx archgen-skill init` at the repo root and ask the user to reload the harness (`§platforms/mcp-configuration`).
6
6
 
7
+ The install writes into the repository — the skill store, the harness bridge, `AGENTS.md`. Those files are **committed to git** (one copy, shared by the team), never gitignored (`§artifact-templates/artifact-tracking`).
8
+
7
9
  The procedures below govern **external** MCP servers (e.g., `context7`) and capability skills.
8
10
 
9
11
  ## INSTALL-MCP procedure (approval-gated)
@@ -33,7 +35,7 @@ Capability-skill loading is **MANDATORY** whenever planned work matches a regist
33
35
  ```
34
36
  This invokes `npx -y skills add <repo> --skill <slug> --agent <agent> --yes --json` to install the single matched skill non-interactively (never a full repo bundle) and verifies the resulting file path.
35
37
  4. **INTEGRATE (inline into worker contract):** Call `archgen_tasks{action:'contract', tasksYaml, taskId}`. It automatically extracts the normative rules (`MUST` / `SHOULD` / `NEVER`), file path, and section index from every matched required skill and embeds them directly into the worker prompt so workers cannot overlook them.
36
- - To inspect a skill manually, call `archgen_skills{action:'load', skillId}` (full rules + index), `{action:'digest', skillId}` (index only), or `{action:'load', skillId, section}` (single section).
38
+ - To inspect a skill manually, call `archgen_skills{action:'load', skillId}` (the normative rules + the section index — never the whole file), `{action:'digest', skillId}` (index only), or `{action:'load', skillId, section}` (single section).
37
39
  5. **AUDIT:** Every worker completion report MUST include an explicit compliance line:
38
40
  - `Skill compliance: followed <skill-slug> (<path/to/SKILL.md>)`
39
41
  - `Skill compliance: NOT followed — <reason>`
@@ -34,10 +34,18 @@ Never hand-edit the `AGENTS.md` registry table (`| Feature | Status | Updated |`
34
34
  Query `archgen_setup{action:'harness_info'}` for exact per-platform MCP schemas:
35
35
  - **Top-level keys vary:**
36
36
  - `mcpServers`: Claude Code, Cursor, Windsurf, Gemini CLI, Antigravity, Kiro, Trae
37
- - `servers`: VS Code / GitHub Copilot
37
+ - `servers`: VS Code (the `.vscode/mcp.json` workspace format)
38
38
  - `mcp.servers.<id>`: OpenCode (nested under `mcp.servers`, never flat `mcp.<id>`)
39
39
  - `context_servers`: Zed
40
40
  - `[mcp_servers.<id>]`: Codex CLI (TOML)
41
+ - **VS Code reads TWO formats.** The VS Code format is `servers` in
42
+ `.vscode/mcp.json`; the PORTABLE format is `mcpServers` in the Copilot
43
+ user's `mcp-config.json` — `$COPILOT_HOME/mcp-config.json`, or
44
+ `~/.copilot/mcp-config.json`. Copilot harness sessions read the portable
45
+ format directly, and `COPILOT_HOME` REPLACES the default (VS Code checks
46
+ only one of the two). The global wiring therefore goes to the Copilot home
47
+ in the portable format, so VS Code stable, VS Code Insiders, the Copilot
48
+ CLI and every workspace see the same server.
41
49
  - **Remote URL keys vary:** `url` vs `serverUrl` vs `httpUrl` (check `harness_info`).
42
50
  - **Automated wiring:** Use `archgen_setup{action:'install_mcp', approve:true}` rather than hand-editing config files.
43
51
 
@@ -10,39 +10,33 @@ stack:
10
10
  database: postgres
11
11
  structure: # planned folder tree defined BEFORE coding
12
12
  - path: src/features/<feature>/
13
- purpose: feature modules (api, internal, colocated tests)
13
+ purpose: feature modules (api, internal)
14
14
  - path: src/shared/
15
15
  purpose: cross-feature utilities; MUST NOT import from features/
16
16
  - path: src/config/
17
17
  purpose: typed env parsing + constants
18
+ - path: tests/unit/features/<feature>/
19
+ purpose: unit tests — the test tree, a SIBLING of src/ that mirrors the source path
18
20
  naming_conventions: # referenced by every task step and file_ownership entry
19
21
  file_naming: kebab-case files; feature folder matches module name
20
- dir_layout: "<module>/{api,internal,tests}; shared utilities in src/shared/ only"
22
+ dir_layout: "<module>/{api,internal}; shared utilities in src/shared/ only; tests mirror the source path under tests/unit/"
21
23
  symbol_casing: PascalCase types, camelCase functions, SCREAMING_SNAKE env vars
22
- test_placement: colocated <name>.test.ts beside unit under test
23
- modules:
24
+ test_placement: tests/unit/<path under the source root>/<the language's test file> — the test tree is a SIBLING of the source tree, never inside it
25
+ modules: # one entry per module; repeat per module
24
26
  - name: catalog
25
27
  responsibility: product browsing + search
26
28
  owns: ["src/features/catalog/**"]
27
29
  depends_on_modules: [] # DAG; drives task depends_on edges
28
30
  key_interfaces: # ONLY exports other modules may import
29
31
  - CatalogApi (src/features/catalog/api/index.ts)
30
- - name: checkout
31
- responsibility: cart -> payment orchestration
32
- owns: ["src/features/checkout/**"]
33
- depends_on_modules: [catalog]
34
- key_interfaces:
35
- - CheckoutApi (src/features/checkout/api/index.ts)
36
- data_contracts:
37
- orders:
32
+ data_contracts: # one block per entity; repeat per entity
33
+ order:
38
34
  storage: postgres table orders (migrations/NNNN_orders.sql)
39
35
  fields:
40
36
  - { field: id, type: uuid, required: true, notes: pk }
41
- - { field: session_id, type: uuid, required: true, notes: fk sessions }
42
37
  - { field: total_cents, type: integer, required: true, notes: ">= 0" }
43
38
  - { field: status, type: "enum(pending,paid,failed)", required: true }
44
- - { field: created_at, type: timestamptz, required: true, notes: db default now() }
45
- api_contracts:
39
+ api_contracts: # one block per operation; repeat per operation
46
40
  payments:
47
41
  - endpoint: /api/checkout/pay
48
42
  method: POST
@@ -52,7 +46,6 @@ api_contracts:
52
46
  error_shape: { code: string, message: string, retryable: boolean }
53
47
  environment_matrix: # unparsed vars fail fast at startup in src/config/
54
48
  - { var: DATABASE_URL, dev: local docker postgres, ci: ephemeral service, prod: secret ref, required: true }
55
- - { var: PAYMENT_API_KEY, dev: stub key, ci: stub key, prod: secret ref, required: true }
56
49
  decisions: # summary index; full records live in decisions/NNNN-title.md
57
50
  - id: D1
58
51
  title: Postgres over MongoDB
@@ -7,7 +7,7 @@ tasks:
7
7
  - id: C
8
8
  title: Scaffold project skeleton + typed config loader
9
9
  depends_on: []
10
- file_ownership: ["src/config/**", "package.json"] # full repo-root-relative paths ONLY
10
+ file_ownership: ["src/config/**", "tests/unit/config/**", "package.json"] # full repo-root-relative paths ONLY
11
11
  acceptance:
12
12
  - "npm run build exits 0"
13
13
  - "config loads from env and fails fast on missing required vars"
@@ -37,7 +37,7 @@ tasks:
37
37
  parallel_group: wave-payments
38
38
  file_ownership:
39
39
  - src/features/checkout/internal/capture.ts # per naming_conventions.dir_layout
40
- - src/features/checkout/internal/capture.test.ts # per naming_conventions.test_placement
40
+ - tests/unit/features/checkout/internal/capture.test.ts # the mirrored test tree
41
41
  - docs/payment-retry.md
42
42
  artifacts: ["docs/payment-retry.md"]
43
43
  acceptance:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archgen-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "MCP server for archgen \u2014 the single runtime that owns architecture-plan validation, wave execution, artifact navigation, CodeGraph bootstrap and harness MCP wiring for coding agents.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cli.mjs CHANGED
@@ -204,7 +204,7 @@ export async function runCli(argv, io = {}) {
204
204
  codegraph: codegraph ? { bin: codegraph.bin, version: codegraph.version } : null,
205
205
  mcpBinary: status.binary,
206
206
  mcpTargets: status.targets,
207
- harnesses: detectHarnesses(registry, ctx.projectRoot, ctx.home).map((d) => d.id),
207
+ harnesses: detectHarnesses(registry, ctx.projectRoot, ctx.home, ctx.env).map((d) => d.id),
208
208
  };
209
209
  if (opts.json) {
210
210
  out(JSON.stringify(report, null, 2));
@@ -225,7 +225,7 @@ export async function runCli(argv, io = {}) {
225
225
  err('uninstall-mcp needs --yes (nothing was removed)');
226
226
  return 2;
227
227
  }
228
- const res = uninstallArchgenMcp(ctx.projectRoot, ctx.home);
228
+ const res = uninstallArchgenMcp(ctx.projectRoot, ctx.home, 'both', ctx.env);
229
229
  if (opts.json) out(JSON.stringify(res, null, 2));
230
230
  else {
231
231
  out(`removed ${res.removed.length} config(s)`);
@@ -263,7 +263,7 @@ export async function manageCodegraph(opts) {
263
263
 
264
264
  /** Wire the MCP server into every detected harness (native + fallback). */
265
265
  const wireHarnesses = async (cli) => {
266
- const detected = detectHarnesses(registry, projectRoot, home);
266
+ const detected = detectHarnesses(registry, projectRoot, home, env);
267
267
  const wanted = targets ?? detected.map((d) => d.id);
268
268
  const selected = detected.filter((d) => wanted.includes(d.id));
269
269
 
@@ -428,7 +428,7 @@ export async function manageCodegraph(opts) {
428
428
  report.cli.latest = m ? m[1] : null;
429
429
  }
430
430
  report.telemetry.state = telemetryState(cli?.bin ?? null);
431
- const detected = detectHarnesses(registry, projectRoot, home);
431
+ const detected = detectHarnesses(registry, projectRoot, home, env);
432
432
  report.detected = detected.map((d) => d.id);
433
433
  for (const d of detected) {
434
434
  for (const cfg of Object.values(d.spec.configs ?? {})) {
@@ -32,6 +32,7 @@ import { buildGraph, GraphError } from './graph.mjs';
32
32
  import { resolveSlugInput, ScopeError } from './scope.mjs';
33
33
  import { parseMarkdown, buildSections, findSection, anchorExists } from './docs.mjs';
34
34
  import { cycle, invalidInput, notFound } from './errors.mjs';
35
+ import { artifactSpaceMissing } from './guidance.mjs';
35
36
 
36
37
  // Overview emits at most this many files (sorted); above it, truncated:true.
37
38
  export const OVERVIEW_MAX_FILES = 100;
@@ -71,7 +72,10 @@ export function resolveDocScope(input, projectRoot = null) {
71
72
  scoped = resolveSlugInput(input, projectRoot);
72
73
  } catch (e) {
73
74
  if (e instanceof ScopeError) throw invalidInput(`doc-index: ${e.message}`, { input });
74
- throw invalidInput(`cannot read ${input} (missing or not a directory)`, { input });
75
+ // The artifact space does not exist yet. Say WHY, and what to do: the
76
+ // scaffold tool returns CONTENT and writes nothing, so the caller has to
77
+ // write it before any artifact tool can read it.
78
+ throw artifactSpaceMissing(null, input);
75
79
  }
76
80
  return {
77
81
  slugDir: scoped.root,
@@ -572,16 +576,32 @@ export function docIndex(input, opts = {}) {
572
576
  if (mode === 'map') {
573
577
  if (!token) throw invalidInput('map mode requires a token', {});
574
578
  // Section-level retrieval: WHERE is this token mentioned?
579
+ //
580
+ // The sections OVERLAP — a parent section's body contains its children's —
581
+ // so a naive walk reports the same line once per containing section. The
582
+ // agent then paid for the same sentence three times to learn one fact.
583
+ // Every hit is reported ONCE, under the DEEPEST section that contains it.
575
584
  const re = new RegExp(wholeWordSource(token));
576
585
  const hits = [];
577
586
  for (const doc of collectDocs().docs) {
578
- for (const section of buildSections(doc)) {
587
+ const sections = buildSections(doc);
588
+ const ownerOf = (line) => {
589
+ let owner = null;
590
+ for (const s of sections) {
591
+ if (line < s.line || line > s.endLine) continue;
592
+ if (owner === null || s.level > owner.level) owner = s;
593
+ }
594
+ return owner;
595
+ };
596
+ for (const section of sections) {
579
597
  const bodyLines = doc.lines.slice(section.line - 1, section.endLine);
580
598
  const matching = [];
581
599
  for (let i = 0; i < bodyLines.length; i++) {
582
- if (re.test(bodyLines[i])) {
583
- matching.push({ line: section.line + i, text: bodyLines[i].trim().slice(0, 200) });
584
- }
600
+ if (!re.test(bodyLines[i])) continue;
601
+ const line = section.line + i;
602
+ const owner = ownerOf(line);
603
+ if (!owner || owner.slug !== section.slug) continue; // its deepest owner reports it
604
+ matching.push({ line, text: bodyLines[i].trim().slice(0, 200) });
585
605
  }
586
606
  if (!matching.length) continue;
587
607
  hits.push({
@@ -597,7 +617,10 @@ export function docIndex(input, opts = {}) {
597
617
  }
598
618
  }
599
619
  hits.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
600
- return { mode, data: { ref: token, sections: hits, sectionCount: hits.length } };
620
+ return {
621
+ mode,
622
+ data: { ref: token, sections: hits, sectionCount: hits.length, matchCount: hits.reduce((n, h) => n + h.occurrences, 0) },
623
+ };
601
624
  }
602
625
 
603
626
  // Overview (scale guard): the deduped inventory is always computed in full
package/src/core/docs.mjs CHANGED
@@ -129,7 +129,22 @@ export function buildSections(doc) {
129
129
  return sections;
130
130
  }
131
131
 
132
- /** Resolve a section by slug, title, or tolerant slug variant. */
132
+ /**
133
+ * Resolve a section by slug, title, tolerant slug variant, or PREFIX.
134
+ *
135
+ * The PREFIX form matters: the skill's `§` markers use SHORT names
136
+ * (`§artifact-templates/plan-depth`) while the headings produce longer
137
+ * slugs (`plan-depth-by-scope-class`). Every `§` shorthand in the skill is
138
+ * a prefix of a real slug, so accepting a prefix makes the shorthand the
139
+ * skill teaches actually RESOLVABLE — an agent can pass exactly what it was
140
+ * told without a discovery round-trip first.
141
+ *
142
+ * A prefix that matches MORE THAN ONE section is refused, never guessed:
143
+ * two sections sharing a prefix means the caller must use the full slug.
144
+ *
145
+ * @param {any} doc
146
+ * @param {string} ref
147
+ */
133
148
  export function findSection(doc, ref) {
134
149
  const sections = buildSections(doc);
135
150
  const wanted = String(ref).trim();
@@ -142,7 +157,14 @@ export function findSection(doc, ref) {
142
157
  // Case-insensitive + collapsed-hyphen comparison: agents type
143
158
  // "FR-Catalog-01" or "fr-catalog-01" for the same heading.
144
159
  const lower = wanted.toLowerCase();
145
- return sections.find((s) => slugsMatch(s.slug.toLowerCase(), lower)) ?? null;
160
+ const byLoose = sections.find((s) => slugsMatch(s.slug.toLowerCase(), lower));
161
+ if (byLoose) return byLoose;
162
+ // The `§` shorthand: a PREFIX of the slug. Only an UNAMBIGUOUS prefix
163
+ // resolves — a guess would send the agent to the wrong section.
164
+ const needle = slugifyHeading(wanted).toLowerCase();
165
+ if (!needle) return null;
166
+ const byPrefix = sections.filter((s) => s.slug.toLowerCase().startsWith(needle));
167
+ return byPrefix.length === 1 ? byPrefix[0] : null;
146
168
  }
147
169
 
148
170
  /** Does `anchor` exist in the target document? */
@@ -18,7 +18,7 @@
18
18
  // skill/scripts/set-status.mjs — the MCP server must not be weaker than the
19
19
  // scripts it replaces.
20
20
  import { lstatSync, mkdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
21
- import { dirname, resolve, sep } from 'node:path';
21
+ import { basename, dirname, join, resolve, sep } from 'node:path';
22
22
  import { conflict, permissionDenied } from './errors.mjs';
23
23
 
24
24
  /** Canonical refusal message — identical wording to the CLI's. */
@@ -41,20 +41,58 @@ export function assertNotSymlink(absPath) {
41
41
  if (st.isSymbolicLink()) throw permissionDenied(refuseSymlinkMessage(absPath), { path: absPath });
42
42
  }
43
43
 
44
+ /**
45
+ * The REAL path of a path, climbing to the nearest existing ancestor when
46
+ * the leaf does not exist yet — so a symlinked PARENT is caught too.
47
+ *
48
+ * Returns null when the chain cannot be resolved at all (a broken symlink,
49
+ * or a component that is not a directory).
50
+ *
51
+ * @param {string} abs
52
+ * @returns {string|null}
53
+ */
54
+ function realPathWithTail(abs) {
55
+ const tail = [];
56
+ let probe = resolve(abs);
57
+ for (;;) {
58
+ try {
59
+ const real = realpathSync(probe);
60
+ return tail.length ? join(real, ...tail) : real;
61
+ } catch (e) {
62
+ if (!e || e.code !== 'ENOENT') return null;
63
+ const parent = dirname(probe);
64
+ if (parent === probe) return null;
65
+ tail.unshift(basename(probe));
66
+ probe = parent;
67
+ }
68
+ }
69
+ }
70
+
44
71
  /**
45
72
  * Realpath-escape guard: refuse anything that lands outside `rootAbs`.
46
73
  *
47
- * TWO CHECKS, IN ORDER.
74
+ * TWO COMPARISONS, AND THE PATH IS ALLOWED IF EITHER SAYS IT IS INSIDE.
75
+ *
76
+ * 1. LEXICAL — the caller's spelling against the root's spelling.
77
+ * 2. REAL — where the path actually LANDS against where the root actually
78
+ * IS. This is the one that catches a symlinked PARENT redirecting a
79
+ * write elsewhere, and it is the only one that can answer the question
80
+ * when the two paths are spelled differently.
48
81
  *
49
- * 1. LEXICAL. After normalization, the path must be inside the root. This
50
- * always applies — it is the primary guard, and it cannot be skipped
51
- * because a path does not exist yet.
52
- * 2. REAL. The nearest existing ancestor is resolved and must also be
53
- * inside the root's real path. This is what catches a symlinked
54
- * PARENT directory that would redirect the write elsewhere.
82
+ * They can disagree, and they DO whenever the root and the path are spelled
83
+ * differently — which is the NORMAL case on macOS (`/tmp`, `/var`) and for
84
+ * any symlinked project: `process.cwd()` is the REAL path while the caller
85
+ * passes the LOGICAL one.
55
86
  *
56
- * The guard does NOT fail open: if the root cannot be resolved, only the
57
- * lexical check stands, and the lexical check is always applied.
87
+ * The old guard compared the LEXICAL path against the REAL root and refused
88
+ * on that mismatch, so a perfectly valid path inside the project was
89
+ * reported as "outside the project" — and the two names the SAME place. The
90
+ * real comparison is the one that can actually answer the question, and it
91
+ * must be allowed to.
92
+ *
93
+ * The guard does NOT fail open: if the real comparison cannot be made, the
94
+ * lexical one stands on its own, and a path outside the root by every
95
+ * measure we could make is always refused.
58
96
  *
59
97
  * @param {string} rootAbs
60
98
  * @param {string} absPath
@@ -62,43 +100,24 @@ export function assertNotSymlink(absPath) {
62
100
  export function assertWithinRoot(rootAbs, absPath) {
63
101
  const root = resolve(rootAbs);
64
102
  const abs = resolve(absPath);
65
- if (abs !== root && !abs.startsWith(root + sep)) {
66
- throw permissionDenied(
67
- `refusing a path outside the project: ${absPath} is not inside ${rootAbs}`,
68
- { path: absPath, root: rootAbs },
69
- );
70
- }
71
103
 
72
- let rootReal;
73
- try {
74
- rootReal = realpathSync(root);
75
- } catch {
76
- return; // the root itself does not exist yet — the lexical check stands
77
- }
78
- let probe = abs;
79
- for (;;) {
80
- let exists = true;
81
- try {
82
- lstatSync(probe);
83
- } catch {
84
- exists = false;
85
- }
86
- if (exists) {
87
- let real;
88
- try {
89
- real = realpathSync(probe);
90
- } catch {
91
- return; // broken chain: nothing writable through it
92
- }
93
- if (real !== rootReal && !real.startsWith(rootReal + sep)) {
94
- throw permissionDenied(refuseSymlinkMessage(absPath), { path: absPath, root: rootAbs });
95
- }
96
- return;
97
- }
98
- const parent = dirname(probe);
99
- if (parent === probe) return;
100
- probe = parent;
104
+ const rootReal = realPathWithTail(root);
105
+ const pathReal = realPathWithTail(abs);
106
+
107
+ const lexicalInside = abs === root || abs.startsWith(root + sep);
108
+ const realInside =
109
+ rootReal && pathReal ? pathReal === rootReal || pathReal.startsWith(rootReal + sep) : null;
110
+
111
+ if (realInside === true || (realInside === null && lexicalInside)) return;
112
+
113
+ if (lexicalInside) {
114
+ // Lexically inside, but it lands somewhere else: a symlinked parent.
115
+ throw permissionDenied(refuseSymlinkMessage(absPath), { path: absPath, root: rootAbs });
101
116
  }
117
+ throw permissionDenied(
118
+ `refusing a path outside the project: ${absPath} is not inside ${rootAbs}`,
119
+ { path: absPath, root: rootAbs },
120
+ );
102
121
  }
103
122
 
104
123
  /**