diff --git a/.changeset/jolly-lynx-run.md b/.changeset/jolly-lynx-run.md new file mode 100644 index 000000000..35028ad4a --- /dev/null +++ b/.changeset/jolly-lynx-run.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3006 +--- +**Trae IDE is now detected as its own runtime** — `/gsd-new-project` and `/gsd-ingest-docs` no longer fall through to the Claude default when run inside Trae, and a `--trae` install no longer writes a malformed `.claude/.trae/rules/` or `.trae/.trae/rules/` instruction-file path; it now resolves to the concrete `.trae/rules/rules.md`. (#2658) diff --git a/bin/install.js b/bin/install.js index 26b3a8940..ff781806f 100755 --- a/bin/install.js +++ b/bin/install.js @@ -2701,10 +2701,55 @@ function convertClaudeToTraeMarkdown(content) { // Replace general-purpose subagent type with Trae's equivalent "general_purpose_task" converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="general_purpose_task"'); converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}'); - converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.trae/rules/`'); - converted = converted.replace(/\.\/CLAUDE\.md/g, '.trae/rules/'); - converted = converted.replace(/`CLAUDE\.md`/g, '`.trae/rules/`'); - converted = converted.replace(/\bCLAUDE\.md\b/g, '.trae/rules/'); + // #2658: full-path forms (with a leading dot-claude-slash prefix) MUST be + // replaced before the bare Claude-instruction-file pattern and before the + // generic dot-claude-slash rewrite below — otherwise the bare pattern + // consumes only the instruction-filename tail, leaving that prefix stale + // in place, and the generic rewrite then mutates the stale leftover too, + // producing a doubled trae-prefix segment ahead of the rules path instead + // of a single clean one. (Deliberately never spelling the instruction + // filename as one contiguous "CLAUDE" + dot + "md" token, and never + // spelling either malformed shape out as a literal contiguous string, in + // ANY comment in this function: this file ships verbatim into local + // `--trae` installs, where it is itself run through this same class of + // find/replace — a literal instruction-filename token sitting in a + // comment gets "fixed" right along with real code, and the emitted-content + // regression test added alongside this fix asserts neither malformed + // shape appears anywhere in the installed tree, comments included; this + // bit the fix itself twice during development.) All forms converge on the + // same concrete file (never a bare directory) so this stays in parity + // with the `trae.js` RUNTIME_CONTENT_DISPATCH entry. + converted = converted.replace(/`\.\/\.claude\/CLAUDE\.md`/g, '`.trae/rules/rules.md`'); + converted = converted.replace(/\.\/\.claude\/CLAUDE\.md/g, '.trae/rules/rules.md'); + converted = converted.replace(/`\.claude\/CLAUDE\.md`/g, '`.trae/rules/rules.md`'); + converted = converted.replace(/\.claude\/CLAUDE\.md/g, '.trae/rules/rules.md'); + // #2658 (found via the end-to-end install regression test, not the static + // trace above): `copyWithPathReplacement` runs a GENERIC dot-claude-slash + // -> runtime-config-dir rewrite on every .md file before calling this + // converter — for `~/.claude/`, `$HOME/.claude/`, AND `./.claude/` alike — + // substituting a runtime-appropriate `pathPrefix` this function is never + // given and cannot itself compute (it differs per install invocation: a + // relative `./.trae/` for a project-local install, an arbitrary absolute + // path for a local install rooted elsewhere, `~/.trae/` for a global one). + // So for source using any of those prefixed forms, the patterns above + // never fire here — this converter only ever sees the ALREADY-rewritten + // "/" + instruction-filename shape, with whatever + // prefix the install actually used. The generic pattern below preserves + // that prefix verbatim (via the capture group) and only fixes the + // filename suffix, rather than assuming a fixed `./.trae/` shape — a + // narrower fixed-prefix version of this pattern shipped first and still + // left the doubled-prefix defect live for the `$HOME/.claude/` and + // `~/.claude/` forms specifically (found the same way, one regression-test + // run later). Scoped to a `.trae/` tail so it cannot also swallow the + // unprefixed `./CLAUDE.md` form the very next pattern handles differently + // (discarding the prefix entirely, not preserving it). Must run before + // the bare pattern for the same consume-the-full-match-first reason. + converted = converted.replace(/`([^\s`]*\.trae\/)CLAUDE\.md`/g, '`$1rules/rules.md`'); + converted = converted.replace(/([^\s`]*\.trae\/)CLAUDE\.md/g, '$1rules/rules.md'); + converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.trae/rules/rules.md`'); + converted = converted.replace(/\.\/CLAUDE\.md/g, '.trae/rules/rules.md'); + converted = converted.replace(/`CLAUDE\.md`/g, '`.trae/rules/rules.md`'); + converted = converted.replace(/\bCLAUDE\.md\b/g, '.trae/rules/rules.md'); converted = converted.replace(/\.claude\/skills\//g, '.trae/skills/'); converted = converted.replace(/\.\/\.claude\//g, './.trae/'); converted = converted.replace(/\.claude\//g, '.trae/'); @@ -7525,7 +7570,18 @@ const RUNTIME_CONTENT_DISPATCH = { return `/gsd-${commandName}`; }); content = content.replace(/\.claude\/skills\//g, '.trae/skills/'); - content = content.replace(/CLAUDE\.md/g, '.trae/rules/'); + // #2658: the full dot-claude-slash-prefixed instruction-file path must + // be replaced before the bare instruction-filename fallback, or the + // bare regex only rewrites that filename and leaves the prefix stale + // in place, producing a malformed doubled-prefix path (see the longer + // note in convertClaudeToTraeMarkdown above — the instruction filename + // and either malformed shape are deliberately never spelled out + // contiguously here either, for the same reason: this file ships + // verbatim). Both forms target the same concrete file (never a bare + // directory), matching the `.md` converter (convertClaudeToTraeMarkdown) + // so js/cjs and md content agree on one canonical path. + content = content.replace(/\.claude\/CLAUDE\.md/g, '.trae/rules/rules.md'); + content = content.replace(/CLAUDE\.md/g, '.trae/rules/rules.md'); content = content.replace(/\bClaude Code\b/g, 'Trae'); return content; }, diff --git a/capabilities/trae/capability.json b/capabilities/trae/capability.json index af4dfea03..c4c39d4e3 100644 --- a/capabilities/trae/capability.json +++ b/capabilities/trae/capability.json @@ -86,7 +86,8 @@ }, "hostBehaviors": { "skipSharedHooksInstall": true, - "soloStageMetadata": "workflow" + "soloStageMetadata": "workflow", + "projectInstructionFile": ".trae/rules/rules.md" } } } diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index b8671555f..694c11d09 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -3384,7 +3384,8 @@ const capabilities = { }, "hostBehaviors": { "skipSharedHooksInstall": true, - "soloStageMetadata": "workflow" + "soloStageMetadata": "workflow", + "projectInstructionFile": ".trae/rules/rules.md" } } }, @@ -6612,7 +6613,8 @@ const runtimes = { }, "hostBehaviors": { "skipSharedHooksInstall": true, - "soloStageMetadata": "workflow" + "soloStageMetadata": "workflow", + "projectInstructionFile": ".trae/rules/rules.md" } } }, diff --git a/gsd-core/workflows/ingest-docs.md b/gsd-core/workflows/ingest-docs.md index 5261d94d0..e6a81341b 100644 --- a/gsd-core/workflows/ingest-docs.md +++ b/gsd-core/workflows/ingest-docs.md @@ -84,9 +84,10 @@ git init - execution_context path `/.codex/` → `RUNTIME=codex` - `/.gemini/` → `RUNTIME=gemini` - `/.opencode/` or `/.config/opencode/` → `RUNTIME=opencode` +- `/.trae/` → `RUNTIME=trae` - else → `RUNTIME=claude` -Fall back to env vars (`CODEX_HOME`, `GEMINI_CONFIG_DIR`, `OPENCODE_CONFIG_DIR`) if execution_context is unavailable. +Fall back to env vars (`CODEX_HOME`, `GEMINI_CONFIG_DIR`, `OPENCODE_CONFIG_DIR`, `TRAE_CONFIG_DIR`) if execution_context is unavailable. diff --git a/gsd-core/workflows/new-project.md b/gsd-core/workflows/new-project.md index 974c5f344..02d209ddb 100644 --- a/gsd-core/workflows/new-project.md +++ b/gsd-core/workflows/new-project.md @@ -101,6 +101,7 @@ Derive `RUNTIME` from the invoking prompt's `execution_context` path: - Path contains `/.codex/` → `RUNTIME=codex` - Path contains `/.gemini/` → `RUNTIME=gemini` - Path contains `/.config/opencode/` or `/.opencode/` → `RUNTIME=opencode` +- Path contains `/.trae/` → `RUNTIME=trae` - Otherwise → `RUNTIME=claude` If `execution_context` path is not available, fall back to env vars: @@ -108,6 +109,7 @@ If `execution_context` path is not available, fall back to env vars: if [ -n "$CODEX_HOME" ]; then RUNTIME="codex" elif [ -n "$GEMINI_CONFIG_DIR" ]; then RUNTIME="gemini" elif [ -n "$OPENCODE_CONFIG_DIR" ] || [ -n "$OPENCODE_CONFIG" ]; then RUNTIME="opencode" +elif [ -n "$TRAE_CONFIG_DIR" ]; then RUNTIME="trae" else RUNTIME="claude"; fi ``` diff --git a/src/runtime-artifact-conversion.cts b/src/runtime-artifact-conversion.cts index 44de4d78d..95f0c4acd 100644 --- a/src/runtime-artifact-conversion.cts +++ b/src/runtime-artifact-conversion.cts @@ -1311,10 +1311,55 @@ function convertClaudeToTraeMarkdown(content) { // Replace general-purpose subagent type with Trae's equivalent "general_purpose_task" converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="general_purpose_task"'); converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}'); - converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.trae/rules/`'); - converted = converted.replace(/\.\/CLAUDE\.md/g, '.trae/rules/'); - converted = converted.replace(/`CLAUDE\.md`/g, '`.trae/rules/`'); - converted = converted.replace(/\bCLAUDE\.md\b/g, '.trae/rules/'); + // #2658: full-path forms (with a leading dot-claude-slash prefix) MUST be + // replaced before the bare Claude-instruction-file pattern and before the + // generic dot-claude-slash rewrite below — otherwise the bare pattern + // consumes only the instruction-filename tail, leaving that prefix stale + // in place, and the generic rewrite then mutates the stale leftover too, + // producing a doubled trae-prefix segment ahead of the rules path instead + // of a single clean one. (Deliberately never spelling the instruction + // filename as one contiguous "CLAUDE" + dot + "md" token, and never + // spelling either malformed shape out as a literal contiguous string, in + // ANY comment in this function: this file ships verbatim into local + // `--trae` installs, where it is itself run through this same class of + // find/replace — a literal instruction-filename token sitting in a + // comment gets "fixed" right along with real code, and the emitted-content + // regression test added alongside this fix asserts neither malformed + // shape appears anywhere in the installed tree, comments included; this + // bit the fix itself twice during development.) All forms converge on the + // same concrete file (never a bare directory) so this stays in parity + // with the `trae.js` RUNTIME_CONTENT_DISPATCH entry. + converted = converted.replace(/`\.\/\.claude\/CLAUDE\.md`/g, '`.trae/rules/rules.md`'); + converted = converted.replace(/\.\/\.claude\/CLAUDE\.md/g, '.trae/rules/rules.md'); + converted = converted.replace(/`\.claude\/CLAUDE\.md`/g, '`.trae/rules/rules.md`'); + converted = converted.replace(/\.claude\/CLAUDE\.md/g, '.trae/rules/rules.md'); + // #2658 (found via the end-to-end install regression test, not the static + // trace above): `copyWithPathReplacement` runs a GENERIC dot-claude-slash + // -> runtime-config-dir rewrite on every .md file before calling this + // converter — for `~/.claude/`, `$HOME/.claude/`, AND `./.claude/` alike — + // substituting a runtime-appropriate `pathPrefix` this function is never + // given and cannot itself compute (it differs per install invocation: a + // relative `./.trae/` for a project-local install, an arbitrary absolute + // path for a local install rooted elsewhere, `~/.trae/` for a global one). + // So for source using any of those prefixed forms, the patterns above + // never fire here — this converter only ever sees the ALREADY-rewritten + // "/" + instruction-filename shape, with whatever + // prefix the install actually used. The generic pattern below preserves + // that prefix verbatim (via the capture group) and only fixes the + // filename suffix, rather than assuming a fixed `./.trae/` shape — a + // narrower fixed-prefix version of this pattern shipped first and still + // left the doubled-prefix defect live for the `$HOME/.claude/` and + // `~/.claude/` forms specifically (found the same way, one regression-test + // run later). Scoped to a `.trae/` tail so it cannot also swallow the + // unprefixed `./CLAUDE.md` form the very next pattern handles differently + // (discarding the prefix entirely, not preserving it). Must run before + // the bare pattern for the same consume-the-full-match-first reason. + converted = converted.replace(/`([^\s`]*\.trae\/)CLAUDE\.md`/g, '`$1rules/rules.md`'); + converted = converted.replace(/([^\s`]*\.trae\/)CLAUDE\.md/g, '$1rules/rules.md'); + converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.trae/rules/rules.md`'); + converted = converted.replace(/\.\/CLAUDE\.md/g, '.trae/rules/rules.md'); + converted = converted.replace(/`CLAUDE\.md`/g, '`.trae/rules/rules.md`'); + converted = converted.replace(/\bCLAUDE\.md\b/g, '.trae/rules/rules.md'); converted = converted.replace(/\.claude\/skills\//g, '.trae/skills/'); converted = converted.replace(/\.\/\.claude\//g, './.trae/'); converted = converted.replace(/\.claude\//g, '.trae/'); diff --git a/tests/emitted-drift-acks/2658-trae-instruction-file-path.json b/tests/emitted-drift-acks/2658-trae-instruction-file-path.json new file mode 100644 index 000000000..83f2f5e8d --- /dev/null +++ b/tests/emitted-drift-acks/2658-trae-instruction-file-path.json @@ -0,0 +1,30 @@ +{ + "version": 1, + "paths": { + "gsd-core/references/checkpoints.md": "#2658: mentions CLAUDE.md in prose. convertClaudeToTraeMarkdown's CLAUDE.md replacement target changed from the bare directory '.trae/rules/' to the concrete file '.trae/rules/rules.md' for every CLAUDE.md mention (bare, ./-prefixed, backtick-wrapped, and the buggy .claude/-prefixed form that previously produced a malformed doubled path) — so trae-emitted output differs for every file that mentions CLAUDE.md, not only the ones that hit the reported bug. Content is otherwise byte-identical.", + "gsd-core/references/debugger-bug-taxonomy.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').", + "gsd-core/references/debugger-fix-acceptance.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').", + "gsd-core/references/debugger-repro-hardening.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').", + "gsd-core/references/debugger-sbfl.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').", + "gsd-core/references/git-integration.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').", + "gsd-core/references/planner-human-verify-mode.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').", + "gsd-core/templates/README.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').", + "gsd-core/templates/claude-md.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').", + "gsd-core/templates/codebase/structure.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').", + "gsd-core/workflows/execute-phase.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').", + "gsd-core/workflows/execute-plan.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').", + "gsd-core/workflows/help/modes/full.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').", + "gsd-core/workflows/milestone-summary.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').", + "gsd-core/workflows/plan-phase.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').", + "gsd-core/workflows/profile-user.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').", + "gsd-core/workflows/progress.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').", + "gsd-core/workflows/quick.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').", + "gsd-core/workflows/sketch-wrap-up.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').", + "gsd-core/workflows/spike-wrap-up.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').", + "gsd-core/workflows/update.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').", + "skills/gsd-ns-project/skills/profile-user/SKILL.md": "#2658: derived (via commands/gsd/profile-user.md, which mentions CLAUDE.md) through convertClaudeToTraeMarkdown; same replacement-target change as checkpoints.md above.", + "skills/gsd-ns-review/skills/code-review/SKILL.md": "#2658: derived (via commands/gsd/code-review.md, which mentions CLAUDE.md) through convertClaudeToTraeMarkdown; same replacement-target change as checkpoints.md above.", + "ingest-docs.md": "#2658: runtime-detection block gained a `/.trae/` path-based line and a `TRAE_CONFIG_DIR` env-var fallback line (the same trae-detection gap found in new-project.md, fixed here too since it is the identical defect in a sibling workflow).", + "new-project.md": "#2658: runtime-detection block gained a `/.trae/` path-based line and a `TRAE_CONFIG_DIR` env-var fallback line, so trae resolves to RUNTIME=trae instead of falling through to the claude default." + } +} diff --git a/tests/fix-2658-trae-runtime-detection-and-instruction-path.test.cjs b/tests/fix-2658-trae-runtime-detection-and-instruction-path.test.cjs new file mode 100644 index 000000000..0da6ae3c3 --- /dev/null +++ b/tests/fix-2658-trae-runtime-detection-and-instruction-path.test.cjs @@ -0,0 +1,255 @@ +'use strict'; + +/** + * Regression tests for #2658 — Trae runtime not detected in workflow + * runtime-detection blocks (falls back to claude), and the install-time + * `CLAUDE.md` path rewrite mutilates the claude fallback into a malformed + * path instead of resolving to the Trae rules file. + * + * Defects collided (see + * .gsd/bug/fix-2658-trae-runtime-not-detected-falls-back-to-/10-diagnosis.md): + * + * 1. `gsd-core/workflows/new-project.md` AND `gsd-core/workflows/ingest-docs.md` + * (found during this remediation — same pattern, same gap, not just + * new-project.md as originally reported) never recognized trae (path + * `/.trae/` or env `TRAE_CONFIG_DIR`) in their runtime-detection blocks — + * fell through to `RUNTIME=claude`. + * 2. The `trae.js` entry in `bin/install.js`'s `RUNTIME_CONTENT_DISPATCH` + * replaced bare `CLAUDE.md` first, leaving a stale `.claude/` prefix: + * `.claude/CLAUDE.md` -> `.claude/.trae/rules/`. + * 3. `convertClaudeToTraeMarkdown` (mirrored in `bin/install.js` and + * `src/runtime-artifact-conversion.cts`) had the same class of bug but a + * DIFFERENT wrong output (`.trae/.trae/rules/`), because its generic + * `.claude/` -> `.trae/` rewrite ran after the bare `CLAUDE.md` rewrite + * and re-mutated the leftover prefix. + * 4. `capabilities/trae/capability.json` didn't declare + * `hostBehaviors.projectInstructionFile`, so even a correctly-detected + * trae runtime resolved to the generic `AGENTS.md` default via + * `getProjectInstructionFile`. + * 5. Found by the end-to-end install test below, one level deeper than the + * static trace: `copyWithPathReplacement` (bin/install.js) runs a + * GENERIC `~/.claude/` / `$HOME/.claude/` / `./.claude/` -> runtime-dir + * rewrite on every .md file BEFORE calling `convertClaudeToTraeMarkdown`, + * substituting a `pathPrefix` the converter is never given (it differs + * per install: relative for a project-local install, an arbitrary + * absolute path for a local install rooted elsewhere, `~/.trae/` for a + * global one). The converter's `.claude/CLAUDE.md`-specific patterns + * (defect 3's fix) never fire on that already-rewritten text, and the + * bare fallback still doubles the prefix — a first attempt at fixing + * this handled only the `./.trae/CLAUDE.md` shape and missed the + * `~/.claude/` / `$HOME/.claude/` forms `gsd-core/workflows/profile-user.md` + * actually uses, caught by row 12 (the real spawned install) below on a + * second run. Fixed with a prefix-preserving pattern (capture whatever + * precedes a `.trae/` tail, keep it, fix only the filename suffix) + * instead of assuming one fixed shape. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const fc = require('fast-check'); + +process.env['GSD_TEST_MODE'] = '1'; + +const { getProjectInstructionFile } = require('../gsd-core/bin/lib/runtime-name-policy.cjs'); +const { convertClaudeToTraeMarkdown } = require('../bin/install.js'); +const runtimeArtifactConversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); + +const { runMinimalInstall, walk } = require('./helpers/install-shared.cjs'); +const { cleanup } = require('./helpers.cjs'); + +const REPO_ROOT = path.join(__dirname, '..'); +const MALFORMED_SINGLE = '.claude/.trae/rules'; +const MALFORMED_DOUBLE = '.trae/.trae/rules'; +const EXPECTED_PATH = '.trae/rules/rules.md'; + +describe('#2658 acceptance criterion 2: getProjectInstructionFile resolves trae to a concrete file', () => { + test('trae maps to .trae/rules/rules.md (not the generic AGENTS.md default)', () => { + assert.strictEqual(getProjectInstructionFile('trae'), EXPECTED_PATH); + }); + + test('capability descriptor declares the same path getProjectInstructionFile returns', () => { + const cap = JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, 'capabilities', 'trae', 'capability.json'), 'utf8'), + ); + assert.strictEqual(cap.runtime.hostBehaviors.projectInstructionFile, EXPECTED_PATH); + assert.strictEqual(getProjectInstructionFile('trae'), cap.runtime.hostBehaviors.projectInstructionFile); + }); + + test('the declared path is a concrete file, not a bare directory (acceptance criterion 2)', () => { + assert.ok(!EXPECTED_PATH.endsWith('/'), 'must not be directory-terminated'); + assert.ok(/\.md$/.test(EXPECTED_PATH), 'must name a concrete markdown file'); + }); +}); + +describe('#2658: convertClaudeToTraeMarkdown never mutilates the CLAUDE.md path (bin/install.js)', () => { + const cases = [ + ['bare CLAUDE.md', 'See CLAUDE.md for details.'], + ['./CLAUDE.md', 'Read ./CLAUDE.md before starting.'], + ['backtick-wrapped `CLAUDE.md`', 'The file `CLAUDE.md` is authoritative.'], + ['the exact reported-bug input: .claude/CLAUDE.md', 'Fallback path is .claude/CLAUDE.md by default.'], + ['backtick-wrapped .claude/CLAUDE.md', 'Fallback: `.claude/CLAUDE.md`.'], + ['./.claude/CLAUDE.md', 'From root: ./.claude/CLAUDE.md'], + ]; + for (const [label, input] of cases) { + test(`${label} -> ${EXPECTED_PATH}, no malformed output`, () => { + const out = convertClaudeToTraeMarkdown(input); + assert.ok(!out.includes(MALFORMED_SINGLE), `output must not contain "${MALFORMED_SINGLE}": ${out}`); + assert.ok(!out.includes(MALFORMED_DOUBLE), `output must not contain "${MALFORMED_DOUBLE}": ${out}`); + assert.ok(out.includes(EXPECTED_PATH), `output must contain "${EXPECTED_PATH}": ${out}`); + }); + } + + test('fast-check property: any surrounding text around .claude/CLAUDE.md never yields a malformed path', () => { + fc.assert( + fc.property( + fc.string({ maxLength: 40 }), + fc.string({ maxLength: 40 }), + (prefix, suffix) => { + const content = `${prefix}.claude/CLAUDE.md${suffix}`; + const out = convertClaudeToTraeMarkdown(content); + assert.ok(!out.includes(MALFORMED_SINGLE)); + assert.ok(!out.includes(MALFORMED_DOUBLE)); + }, + ), + { numRuns: 200 }, + ); + }); +}); + +describe('#2658 defect 5: post-generic-rewrite ".trae/"-prefixed forms preserve their prefix instead of doubling it', () => { + // These simulate the text `copyWithPathReplacement`'s generic `~/.claude/` / + // `$HOME/.claude/` / `./.claude/` -> runtime-dir pass hands to + // convertClaudeToTraeMarkdown — the converter never sees the original + // `.claude/`-prefixed source in this pipeline, only these already-rewritten + // shapes. A fixed-shape patch that only handled the local relative form + // left the local-install-absolute-path and global tilde forms broken. + const cases = [ + ['local relative (post "./.claude/" -> "./.trae/" rewrite)', './.trae/CLAUDE.md', './.trae/rules/rules.md'], + [ + 'local install absolute path (post "./.claude/" -> "/.trae/" rewrite)', + '/private/var/folders/xx/gsd-trae-local-abc123/.trae/CLAUDE.md', + '/private/var/folders/xx/gsd-trae-local-abc123/.trae/rules/rules.md', + ], + ['global tilde (post "~/.claude/" -> "~/.trae/" rewrite)', '~/.trae/CLAUDE.md', '~/.trae/rules/rules.md'], + ['backtick-wrapped local relative', '`./.trae/CLAUDE.md`', '`./.trae/rules/rules.md`'], + ]; + for (const [label, input, expected] of cases) { + test(`${label} -> prefix preserved, no malformed path`, () => { + const out = convertClaudeToTraeMarkdown(input); + assert.ok(!out.includes(MALFORMED_SINGLE), `output must not contain "${MALFORMED_SINGLE}": ${out}`); + assert.ok(!out.includes(MALFORMED_DOUBLE), `output must not contain "${MALFORMED_DOUBLE}": ${out}`); + assert.strictEqual(out, expected); + }); + } + + test('fast-check property: any arbitrary path ending in .trae/ never yields a doubled prefix', () => { + fc.assert( + fc.property( + fc.string({ maxLength: 30 }).filter((s) => !s.includes('`') && !/\s/.test(s)), + (prefix) => { + const content = `${prefix}.trae/CLAUDE.md`; + const out = convertClaudeToTraeMarkdown(content); + assert.ok(!out.includes(MALFORMED_SINGLE)); + assert.ok(!out.includes(MALFORMED_DOUBLE)); + }, + ), + { numRuns: 200 }, + ); + }); +}); + +describe('#2658 output parity: bin/install.js vs runtime-artifact-conversion.cjs convertClaudeToTraeMarkdown (#2094 mirror)', () => { + // Parity must hold for the pre-existing reported-bug input AND for every + // arbitrary-prefix ".trae/"-tail shape the prefix-preserving regex + // (bin/install.js:2747-2748, mirrored byte-for-byte at + // src/runtime-artifact-conversion.cts:1357-1358) was added to handle. A + // change to only one copy of that regex would otherwise pass every other + // test in this file — none of the defect-5 cases above call the mirror — + // while silently diverging from the other copy. + const parityCases = [ + ['the reported-bug input (bare .claude/ prefix)', 'Fallback path is .claude/CLAUDE.md by default.'], + ['local relative prefix (post "./.claude/" -> "./.trae/" rewrite)', './.trae/CLAUDE.md'], + [ + 'nested project-path absolute prefix (post "./.claude/" -> "/.trae/" rewrite)', + '/private/var/folders/xx/gsd-trae-local-abc123/.trae/CLAUDE.md', + ], + ['global tilde prefix (post "~/.claude/" -> "~/.trae/" rewrite)', '~/.trae/CLAUDE.md'], + ['$HOME-variable prefix (post "$HOME/.claude/" -> "$HOME/.trae/" rewrite)', '$HOME/.trae/CLAUDE.md'], + ['backtick-wrapped local relative prefix', '`./.trae/CLAUDE.md`'], + ]; + + for (const [label, input] of parityCases) { + test(`identical output for ${label}`, () => { + assert.strictEqual( + convertClaudeToTraeMarkdown(input), + runtimeArtifactConversion.convertClaudeToTraeMarkdown(input), + ); + }); + } + + test('fast-check property: any arbitrary ".trae/"-tail path produces identical output in both implementations', () => { + fc.assert( + fc.property( + fc.string({ maxLength: 30 }).filter((s) => !s.includes('`') && !/\s/.test(s)), + (prefix) => { + const content = `${prefix}.trae/CLAUDE.md`; + assert.strictEqual( + convertClaudeToTraeMarkdown(content), + runtimeArtifactConversion.convertClaudeToTraeMarkdown(content), + ); + }, + ), + { numRuns: 200 }, + ); + }); +}); + +describe('#2658: end-to-end --trae install never emits the malformed path (acceptance criterion 1)', () => { + test('local install: no emitted .md/.js/.cjs file contains the malformed strings; the rules file is concrete', () => { + const { configDir, root } = runMinimalInstall({ runtime: 'trae', scope: 'local' }); + try { + const files = walk(configDir).filter((f) => /\.(md|js|cjs)$/.test(f)); + assert.ok(files.length > 0, 'expected at least one emitted .md/.js/.cjs file'); + for (const file of files) { + const content = fs.readFileSync(file, 'utf8'); + assert.ok(!content.includes(MALFORMED_SINGLE), `${file} must not contain "${MALFORMED_SINGLE}"`); + assert.ok(!content.includes(MALFORMED_DOUBLE), `${file} must not contain "${MALFORMED_DOUBLE}"`); + } + } finally { + cleanup(root); + } + }); +}); + +describe('#2658 acceptance criterion 3: new-project.md / ingest-docs.md detect trae before falling back to claude', () => { + const workflowsDir = path.join(REPO_ROOT, 'gsd-core', 'workflows'); + + test('new-project.md recognizes /.trae/ path and TRAE_CONFIG_DIR before the claude fallback', () => { + const content = fs.readFileSync(path.join(workflowsDir, 'new-project.md'), 'utf8'); + const pathBlock = content.match(/Derive `RUNTIME`[\s\S]*?Otherwise → `RUNTIME=claude`/); + assert.ok(pathBlock, 'runtime-detection path block must exist'); + assert.ok( + /Path contains `\/\.trae\/` → `RUNTIME=trae`/.test(pathBlock[0]), + 'path-based detection must recognize /.trae/ before the claude fallback', + ); + const envBlock = content.match(/if \[ -n "\$CODEX_HOME" \][\s\S]*?else RUNTIME="claude"; fi/); + assert.ok(envBlock, 'env-var fallback block must exist'); + assert.ok( + /TRAE_CONFIG_DIR/.test(envBlock[0]), + 'env-var fallback must recognize TRAE_CONFIG_DIR before the claude fallback', + ); + }); + + test('ingest-docs.md carries the same trae detection (found during this remediation, not just new-project.md)', () => { + const content = fs.readFileSync(path.join(workflowsDir, 'ingest-docs.md'), 'utf8'); + const block = content.match(/\*\*Detect runtime\*\*[\s\S]*?else → `RUNTIME=claude`/); + assert.ok(block, 'runtime-detection block must exist'); + assert.ok( + /`\/\.trae\/` → `RUNTIME=trae`/.test(block[0]), + 'ingest-docs.md must also recognize /.trae/ before the claude fallback', + ); + assert.ok(/TRAE_CONFIG_DIR/.test(content), 'env-var fallback mention must include TRAE_CONFIG_DIR'); + }); +});