Skip to content

Commit 12d6f8d

Browse files
committed
feat(placeholders): add apm-input-token for APM \${input:NAME} prompt parameters
Add a new `apm-input-token` to the closed placeholder vocabulary so that MDS023, MDS024, MDS018, and MDS004 can treat APM `${input:NAME}` template parameters as opaque rather than prose content. The token matches the documented APM grammar `[A-Za-z][\w-]{0,63}` and is wired into ContainsBodyToken, MaskBodyTokens, and stripBodyTokens with a package-scope compiled regexp per the allocation budget policy. Closes plan 2607082048. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Cua8NLiPy6fhj58LvUWf1
1 parent 5124cbc commit 12d6f8d

5 files changed

Lines changed: 105 additions & 31 deletions

File tree

PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ footer: |
245245
| 2607051919 || sonnet | [Add dedicated unit tests for helpers added by the word-list and reflow features](plan/2607051919_arch-fix-new-helper-tests.md) |
246246
| 2607051920 || sonnet | [Consolidate duplicated leading-space/blank-line rule helpers into internal/rules/astutil](plan/2607051920_arch-fix-rule-whitespace-helpers-astutil.md) |
247247
| 2607071642 | 🔲 | sonnet | [Single-file metric extraction via `mdsmith metrics get` (readability first)](plan/2607071642_extractable-file-metrics.md) |
248-
| 2607082048 | 🔲 | sonnet | [Placeholder token for APM `${input:name}` prompt parameters](plan/2607082048_apm-input-placeholder-token.md) |
248+
| 2607082048 | | sonnet | [Placeholder token for APM `${input:name}` prompt parameters](plan/2607082048_apm-input-placeholder-token.md) |
249249
| 2607082049 | 🔲 | opus | [Foreign managed-region protection for `mdsmith fix`](plan/2607082049_foreign-managed-regions.md) |
250250
| 2607082050 | 🔲 | sonnet | [APM coexistence: `mdsmith init --apm`, guide, and kind pack](plan/2607082050_apm-coexist-guide-and-kind-pack.md) |
251251
| 2607082051 | 🔲 | opus | [Schema extensions: closed frontmatter and filename agreement](plan/2607082051_apm-schema-extensions.md) |

docs/background/concepts/placeholder-grammar.md

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@ trigger false positives.
1414

1515
## Token vocabulary
1616

17-
| Token name | Matches |
18-
| --------------------- | ---------------------------------------------------------------- |
19-
| `var-token` | `{identifier}` interpolation placeholders (`{title}`, `{a.b.c}`) |
20-
| `heading-question` | A heading whose text is exactly `?` |
21-
| `placeholder-section` | A heading whose text is exactly `...` |
22-
| `cue-frontmatter` | CUE constraint expressions in front-matter values |
17+
| Token name | Matches |
18+
| --------------------- | ------------------------------------------------------------------------------------------ |
19+
| `var-token` | `{identifier}` interpolation placeholders (`{title}`, `{a.b.c}`) |
20+
| `heading-question` | A heading whose text is exactly `?` |
21+
| `placeholder-section` | A heading whose text is exactly `...` |
22+
| `cue-frontmatter` | CUE constraint expressions in front-matter values |
23+
| `apm-input-token` | APM prompt parameters of the form `${input:NAME}` where NAME matches `[A-Za-z][\w-]{0,63}` |
2324

2425
The vocabulary is closed: the token list lives in one place inside
2526
the engine, and no rule hardcodes token names in its own logic.
@@ -71,12 +72,12 @@ from `internal/placeholders`:
7172

7273
## Opt-in rules
7374

74-
| Rule ID | Rule name | Useful tokens |
75-
| ------- | -------------------------------- | ------------------------------------------------------ |
76-
| MDS003 | `heading-increment` | `heading-question`, `placeholder-section`, `var-token` |
77-
| MDS004 | `first-line-heading` | `heading-question`, `var-token`, `placeholder-section` |
78-
| MDS018 | `no-emphasis-as-heading` | `var-token`, `heading-question`, `placeholder-section` |
79-
| MDS020 | `required-structure` | `cue-frontmatter` |
80-
| MDS023 | `paragraph-readability` | `var-token`, `heading-question`, `placeholder-section` |
81-
| MDS024 | `paragraph-structure` | `var-token`, `heading-question`, `placeholder-section` |
82-
| MDS027 | `cross-file-reference-integrity` | `var-token`, `heading-question`, `placeholder-section` |
75+
| Rule ID | Rule name | Useful tokens |
76+
| ------- | -------------------------------- | ------------------------------------------------------------------------- |
77+
| MDS003 | `heading-increment` | `heading-question`, `placeholder-section`, `var-token` |
78+
| MDS004 | `first-line-heading` | `heading-question`, `var-token`, `placeholder-section` |
79+
| MDS018 | `no-emphasis-as-heading` | `var-token`, `heading-question`, `placeholder-section`, `apm-input-token` |
80+
| MDS020 | `required-structure` | `cue-frontmatter` |
81+
| MDS023 | `paragraph-readability` | `var-token`, `heading-question`, `placeholder-section`, `apm-input-token` |
82+
| MDS024 | `paragraph-structure` | `var-token`, `heading-question`, `placeholder-section`, `apm-input-token` |
83+
| MDS027 | `cross-file-reference-integrity` | `var-token`, `heading-question`, `placeholder-section` |

internal/placeholders/placeholders.go

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,40 +6,48 @@
66
// Configurable interface. When checking a node it calls ContainsBodyToken
77
// or MaskBodyTokens to decide whether to skip or neutralize the content.
88
//
9-
// The four initial tokens are:
9+
// The five tokens are:
1010
//
11-
// - var-token — {identifier} interpolation placeholders
12-
// - heading-question — headings whose text is exactly "?"
11+
// - var-token — {identifier} interpolation placeholders
12+
// - heading-question — headings whose text is exactly "?"
1313
// - placeholder-section — headings whose text is exactly "..."
14-
// - cue-frontmatter — CUE constraint expressions in front-matter values
14+
// - cue-frontmatter — CUE constraint expressions in front-matter values
15+
// - apm-input-token — APM ${input:NAME} prompt parameters
1516
package placeholders
1617

1718
import (
1819
"fmt"
20+
"regexp"
1921
"strings"
2022

2123
"github.com/jeduden/mdsmith/internal/fieldinterp"
2224
)
2325

26+
// apmInputRe matches APM ${input:NAME} prompt parameters.
27+
// NAME must start with a letter and contain only letters, digits, underscores, and hyphens.
28+
var apmInputRe = regexp.MustCompile(`\$\{input:[A-Za-z][\w-]{0,63}\}`)
29+
2430
// Named placeholder tokens.
2531
const (
2632
VarToken = "var-token"
2733
HeadingQuestion = "heading-question"
2834
PlaceholderSection = "placeholder-section"
2935
CUEFrontmatter = "cue-frontmatter"
36+
APMInputToken = "apm-input-token"
3037
)
3138

3239
// neutralText is the neutral replacement per body token.
3340
var neutralText = map[string]string{
3441
VarToken: "word",
3542
HeadingQuestion: "Placeholder",
3643
PlaceholderSection: "Placeholder Section",
44+
APMInputToken: "word",
3745
}
3846

3947
// IsKnown reports whether name is a recognized token name.
4048
func IsKnown(name string) bool {
4149
switch name {
42-
case VarToken, HeadingQuestion, PlaceholderSection, CUEFrontmatter:
50+
case VarToken, HeadingQuestion, PlaceholderSection, CUEFrontmatter, APMInputToken:
4351
return true
4452
}
4553
return false
@@ -72,6 +80,10 @@ func ContainsBodyToken(text string, tokens []string) bool {
7280
if strings.TrimSpace(text) == "..." {
7381
return true
7482
}
83+
case APMInputToken:
84+
if apmInputRe.MatchString(text) {
85+
return true
86+
}
7587
}
7688
}
7789
return false
@@ -80,8 +92,8 @@ func ContainsBodyToken(text string, tokens []string) bool {
8092
// MaskBodyTokens replaces placeholder token patterns in text with neutral
8193
// content. cue-frontmatter is not a body token and is left unchanged.
8294
// Whole-text tokens (heading-question, placeholder-section) replace the
83-
// entire text when they match; substring tokens (var-token) replace each
84-
// occurrence in place.
95+
// entire text when they match; substring tokens (var-token, apm-input-token)
96+
// replace each occurrence in place.
8597
func MaskBodyTokens(text string, tokens []string) string {
8698
for _, tok := range tokens {
8799
switch tok {
@@ -96,6 +108,8 @@ func MaskBodyTokens(text string, tokens []string) string {
96108
if strings.TrimSpace(text) == "..." {
97109
return neutralText[PlaceholderSection]
98110
}
111+
case APMInputToken:
112+
text = apmInputRe.ReplaceAllLiteralString(text, neutralText[APMInputToken])
99113
}
100114
}
101115
return text
@@ -129,6 +143,8 @@ func stripBodyTokens(text string, tokens []string) string {
129143
if strings.TrimSpace(text) == "..." {
130144
return ""
131145
}
146+
case APMInputToken:
147+
text = apmInputRe.ReplaceAllLiteralString(text, "")
132148
}
133149
}
134150
return text

internal/placeholders/placeholders_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,3 +292,60 @@ func TestHasCUEFrontmatter(t *testing.T) {
292292
assert.False(t, placeholders.HasCUEFrontmatter(nil))
293293
assert.False(t, placeholders.HasCUEFrontmatter([]string{}))
294294
}
295+
296+
func TestAPMInputToken_IsKnown(t *testing.T) {
297+
assert.True(t, placeholders.IsKnown(placeholders.APMInputToken))
298+
}
299+
300+
func TestAPMInputToken_ContainsBodyToken(t *testing.T) {
301+
tok := []string{placeholders.APMInputToken}
302+
tests := []struct {
303+
name string
304+
text string
305+
want bool
306+
}{
307+
{"simple input param", "${input:pr_url}", true},
308+
{"hyphenated name", "${input:focus-area}", true},
309+
{"mixed case start", "${input:FocusArea}", true},
310+
{"embedded in sentence", "review ${input:pr_url} now", true},
311+
{"multiple tokens", "${input:a} and ${input:b}", true},
312+
{"non-matching form output", "${output:x}", false},
313+
{"no dollar", "{input:x}", false},
314+
{"space in name", "${input:bad name}", false},
315+
{"not input prefix", "${notinput:x}", false},
316+
{"empty text", "", false},
317+
{"plain text", "some prose", false},
318+
}
319+
for _, tt := range tests {
320+
t.Run(tt.name, func(t *testing.T) {
321+
assert.Equal(t, tt.want, placeholders.ContainsBodyToken(tt.text, tok))
322+
})
323+
}
324+
}
325+
326+
func TestAPMInputToken_MaskBodyTokens(t *testing.T) {
327+
tok := []string{placeholders.APMInputToken}
328+
tests := []struct {
329+
name string
330+
text string
331+
want string
332+
}{
333+
{"single token replaced", "${input:pr_url}", "word"},
334+
{"token in sentence", "review ${input:pr_url} now", "review word now"},
335+
{"two tokens replaced", "${input:a} and ${input:b}", "word and word"},
336+
{"non-matching unchanged", "${output:x}", "${output:x}"},
337+
{"plain text unchanged", "some prose", "some prose"},
338+
}
339+
for _, tt := range tests {
340+
t.Run(tt.name, func(t *testing.T) {
341+
assert.Equal(t, tt.want, placeholders.MaskBodyTokens(tt.text, tok))
342+
})
343+
}
344+
}
345+
346+
func TestAPMInputToken_IsAllBodyTokens(t *testing.T) {
347+
tok := []string{placeholders.APMInputToken}
348+
assert.True(t, placeholders.IsAllBodyTokens("${input:pr_url}", tok))
349+
assert.True(t, placeholders.IsAllBodyTokens("${input:a} ${input:b}", tok))
350+
assert.False(t, placeholders.IsAllBodyTokens("${input:a} extra", tok))
351+
}

plan/2607082048_apm-input-placeholder-token.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
id: 2607082048
33
title: "Placeholder token for APM `${input:name}` prompt parameters"
4-
status: "🔲"
4+
status: ""
55
model: sonnet
66
summary: >-
77
Add one token to the closed placeholder vocabulary that matches
@@ -77,17 +77,17 @@ in the
7777

7878
## Acceptance Criteria
7979

80-
- [ ] A paragraph whose only non-prose content is
80+
- [x] A paragraph whose only non-prose content is
8181
`${input:pr_url}` produces no MDS023 or MDS024
8282
diagnostic when the token is configured.
83-
- [ ] The token name appears in the placeholder
83+
- [x] The token name appears in the placeholder
8484
vocabulary table and the opt-in rule list.
85-
- [ ] The matcher rejects `${notinput:x}` and
85+
- [x] The matcher rejects `${notinput:x}` and
8686
`${input:bad name}` (space is outside the
8787
grammar).
88-
- [ ] `Check` stays within the allocation budget
88+
- [x] `Check` stays within the allocation budget
8989
(regexp compiled at package scope).
90-
- [ ] All tests pass: `go test ./...`
91-
- [ ] `go tool -modfile=tools/go.mod golangci-lint
90+
- [x] All tests pass: `go test ./...`
91+
- [x] `go tool -modfile=tools/go.mod golangci-lint
9292
run` reports no issues.
93-
- [ ] `mdsmith check .` — 0 failures.
93+
- [x] `mdsmith check .` — 0 failures.

0 commit comments

Comments
 (0)