diff --git a/CONTEXT.md b/CONTEXT.md index 95c20b236..544919edd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -722,6 +722,14 @@ A legal deferred state of an Execute step (`external_job_waiting`): the executor `DEFECT.WINDOWS-TEST-PORTABILITY.fix-forward=gate platform-specific execution with if (process.platform !== 'win32'); normalize path expectations to forward slashes with .replace(/\\/g, '/'); invoke scripts via explicit interpreter (sh ) rather than relying on exec-bit; annotate // windows-portability-ok: when a bypass is intentional` `DEFECT.WINDOWS-TEST-PORTABILITY.prevention=run lint:ci before opening a PR; treat the CI windows lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute for it` +`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.symptom=path.join() result on Windows (backslashes) substituted verbatim into markdown body (@-references, workflow files, generated docs); content gains mixed separators; cross-platform substring assertions fail on windows-latest CI lane only; macOS/Linux CI green so defect ships undetected` +`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.examples=PR #1622 computePathPrefix returned ${resolvedTarget}/ verbatim — rewrites of @~/.claude/gsd-core/commands/gsd/X.md wrote @C:\...\gsd-ial-windsurf-XXX\gsd-core/commands/gsd/help.md (trailing forward slashes from the original literal survived, prefix backslashes did not); tests/install-runtime-artifacts.test.cjs:318 + tests/install.test.cjs:1323 failed on windows-latest only` +`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.detect=any function returning a filesystem path that flows into markdown/text body substitution; grep for path.join/raw resolvedTarget/${configDir}/ in code paths writing workflow .md, agent .md, or generated docs; smoke pattern is ${resolvedTarget}/ or ${configDir}/... templates that bypass normalization` +`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.fix-forward=normalize at the SOURCE not the test: posixTarget=String(resolvedTarget).replace(/\\/g,'/'), posixHome=homeDir?String(homeDir).replace(/\\/g,'/'):homeDir; markdown body is POSIX-only; .replace(/\\/g,'/') is idempotent on POSIX (no backslashes present) so safe to apply unconditionally; isWindowsHost arg is a no-op tripwire (enh-1511) — do NOT branch on it, normalize always` +`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.prevention=RULESET.CONTENT-PATH-NORMALIZATION; tests are downstream signal, never the fix; ref DEFECT.WINDOWS-TEST-PORTABILITY for test-side parity (normalize expected substrings too: ${configDir}/foo.replace(/\\/g,'/'))` + +`RULESET.CONTENT-PATH-NORMALIZATION=filesystem paths substituted into markdown body text (@-references, workflow .md, agent .md, generated docs, command bodies) MUST be normalized to POSIX forward slashes via .replace(/\\/g,'/') at the production source BEFORE substitution; never push normalization to tests; cross-platform content is POSIX-only; applies to: computePathPrefix output, install-path rewrites, generated shim paths emitted into .md bodies; idempotent on POSIX so unconditional` + --- diff --git a/src/runtime-artifact-conversion.cts b/src/runtime-artifact-conversion.cts index 7f8f48d10..1f244cf5e 100644 --- a/src/runtime-artifact-conversion.cts +++ b/src/runtime-artifact-conversion.cts @@ -2169,10 +2169,18 @@ function convertClaudeCommandToKiloSkill(content, skillName) { * @private — exported as `_computePathPrefix` for tests. */ function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost, resolvedTarget, homeDir }) { - if (isGlobal && resolvedTarget.startsWith(homeDir) && !isOpencode) { - return '$HOME' + resolvedTarget.slice(homeDir.length) + '/'; + // #1615: normalize Windows backslashes to forward slashes. This prefix is + // substituted into markdown @-references (e.g. Windsurf workflow files), + // which use POSIX paths universally. Idempotent on POSIX (no backslashes). + // Without this, path.join on Windows produces a backslash prefix that + // leaks into markdown content and breaks cross-platform substring checks. + // See DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT in CONTEXT.md. + const posixTarget = String(resolvedTarget).replace(/\\/g, '/'); + const posixHome = homeDir ? String(homeDir).replace(/\\/g, '/') : homeDir; + if (isGlobal && posixTarget.startsWith(posixHome) && !isOpencode) { + return '$HOME' + posixTarget.slice(posixHome.length) + '/'; } - return `${resolvedTarget}/`; + return `${posixTarget}/`; } /** diff --git a/tests/enh-1511-rewrite-engine-relocation.test.cjs b/tests/enh-1511-rewrite-engine-relocation.test.cjs index 6321ed053..949e7c2b9 100644 --- a/tests/enh-1511-rewrite-engine-relocation.test.cjs +++ b/tests/enh-1511-rewrite-engine-relocation.test.cjs @@ -91,6 +91,22 @@ describe('_computePathPrefix', () => { assert.equal(withWindows, '$HOME/.cursor/'); assert.strictEqual(withWindows, withoutWindows); }); + + test('backslash-style resolvedTarget is normalized to forward slashes (#1615 regression)', () => { + // path.join on Windows produces backslashes; the returned prefix is + // substituted into markdown @-references which must use POSIX paths. + // Without normalization the backslashes leak into workflow file content + // and break substring checks on Windows CI. + const prefix = conversion._computePathPrefix({ + isGlobal: false, + isOpencode: false, + isWindowsHost: true, + resolvedTarget: 'C:\\Users\\runner\\AppData\\Local\\Temp\\gsd-1615-windsurf', + homeDir: 'C:\\Users\\runner', + }); + assert.strictEqual(prefix, 'C:/Users/runner/AppData/Local/Temp/gsd-1615-windsurf/'); + assert.ok(!prefix.includes('\\'), `prefix must not contain backslashes: ${prefix}`); + }); }); // --------------------------------------------------------------------------- diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index 863305e57..0132978d8 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -315,7 +315,7 @@ describe('installRuntimeArtifacts — windsurf workflows layout (#1615)', () => const helpContent = fs.readFileSync(path.join(workflowsDir, 'gsd-help.md'), 'utf8'); assert.ok(!helpContent.startsWith('---'), 'Windsurf workflows must be plain markdown, not SKILL.md frontmatter'); assert.match(helpContent, /# gsd-help/, 'workflow should identify the slash command it backs'); - assert.ok(helpContent.includes(`${configDir}/gsd-core/commands/gsd/help.md`), + assert.ok(helpContent.includes(`${configDir}/gsd-core/commands/gsd/help.md`.replace(/\\/g, '/')), 'workflow should reference the installed command body using the actual install target'); for (const fileName of fs.readdirSync(workflowsDir)) { diff --git a/tests/install.test.cjs b/tests/install.test.cjs index db2cb94ee..22ab9e9d6 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -1320,7 +1320,7 @@ describe('windsurf local install writes workflow slash commands (#1615)', () => const workflowFile = path.join(workflowsDir, workflowEntry.name); const content = fs.readFileSync(workflowFile, 'utf8'); assert.ok( - content.includes(`${tmpDir}/.windsurf/gsd-core/commands/gsd/`), + content.includes(`${tmpDir}/.windsurf/gsd-core/commands/gsd/`.replace(/\\/g, '/')), `${workflowEntry.name} must reference the installed canonical command body`, ); assert.ok(