fix(prompts): make template token substitution order-independent
RenderTemplate replaced tokens one key at a time over a StringBuilder, so a token appearing inside an already-substituted value got substituted again on a later pass. The prompt briefs only escaped this because their callers happen to pass "tasks" last -- reordering the dictionary or adding a fourth token would have started rewriting user-authored task descriptions, which after the enhance phase carry file paths and config snippets. Single-pass regex over the template instead; unknown tokens still pass through.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ClaudeDo.Data;
|
||||
|
||||
@@ -193,14 +194,16 @@ public static class PromptFiles
|
||||
public static string Render(PromptKind kind, IReadOnlyDictionary<string, string> values)
|
||||
=> RenderTemplate(ReadOrDefault(kind), values);
|
||||
|
||||
/// <summary>Replace only the given {name} tokens; any other braces pass through untouched.</summary>
|
||||
private static readonly Regex TokenPattern = new(@"\{(\w+)\}", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>Replace only the given {name} tokens; any other braces pass through untouched.
|
||||
/// Single pass over the template, so a token appearing inside a substituted VALUE is never
|
||||
/// re-substituted. That matters because the values are user-authored task titles and
|
||||
/// descriptions: a sharpened description mentioning a literal {repo} must survive verbatim,
|
||||
/// and it must not depend on the caller happening to order its dictionary correctly.</summary>
|
||||
public static string RenderTemplate(string template, IReadOnlyDictionary<string, string> values)
|
||||
{
|
||||
var sb = new StringBuilder(template);
|
||||
foreach (var (key, val) in values)
|
||||
sb.Replace("{" + key + "}", val);
|
||||
return sb.ToString();
|
||||
}
|
||||
=> TokenPattern.Replace(template, m =>
|
||||
values.TryGetValue(m.Groups[1].Value, out var val) ? val : m.Value);
|
||||
|
||||
public static string DefaultFor(PromptKind kind) => kind switch
|
||||
{
|
||||
|
||||
@@ -22,6 +22,42 @@ public class PromptFilesTests
|
||||
Assert.Equal("## {Wochentag}, {dd.MM.yyyy} — 01.06.2026", outp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderTemplate_does_not_substitute_tokens_that_appear_inside_a_value()
|
||||
{
|
||||
// A sharpened task description can legitimately contain "{repo}" (a config snippet, a
|
||||
// path placeholder). Substitution must be a single pass over the TEMPLATE, so injected
|
||||
// values are never rescanned.
|
||||
var outp = PromptFiles.RenderTemplate(
|
||||
"Repo: {repo}\n\n{tasks}",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["repo"] = "C:\\real\\repo",
|
||||
["tasks"] = "- Fix the {repo} placeholder in the config template",
|
||||
});
|
||||
|
||||
Assert.Equal("Repo: C:\\real\\repo\n\n- Fix the {repo} placeholder in the config template", outp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderTemplate_result_is_independent_of_dictionary_order()
|
||||
{
|
||||
const string template = "Scope: {scope}\nRepo: {repo}\n\n{tasks}";
|
||||
var task = "- Document {scope} and {repo} tokens";
|
||||
|
||||
var tasksLast = PromptFiles.RenderTemplate(template, new Dictionary<string, string>
|
||||
{
|
||||
["scope"] = "List: Bugs", ["repo"] = "C:\\repo", ["tasks"] = task,
|
||||
});
|
||||
var tasksFirst = PromptFiles.RenderTemplate(template, new Dictionary<string, string>
|
||||
{
|
||||
["tasks"] = task, ["repo"] = "C:\\repo", ["scope"] = "List: Bugs",
|
||||
});
|
||||
|
||||
Assert.Equal(tasksLast, tasksFirst);
|
||||
Assert.Contains("- Document {scope} and {repo} tokens", tasksLast);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultFor_system_mentions_blocked_marker_and_scope()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user