Skip to content

Commit 626076d

Browse files
author
merge-queue-bot
committed
Merge PR #743: feat(placeholders): add apm-input-token for APM ${input:NAME} prompt parameters
2 parents 5124cbc + 6db7cd4 commit 626076d

11 files changed

Lines changed: 119 additions & 37 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`, `apm-input-token` |
78+
| MDS004 | `first-line-heading` | `heading-question`, `var-token`, `placeholder-section`, `apm-input-token` |
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`, `apm-input-token` |

internal/placeholders/placeholders.go

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,40 +6,49 @@
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+
// apmInputPrefix is the byte sequence that uniquely opens an APM input parameter.
27+
const apmInputPrefix = "${input:"
28+
29+
var apmInputRe = regexp.MustCompile(`\$\{input:[A-Za-z][\w-]{0,63}\}`)
30+
2431
// Named placeholder tokens.
2532
const (
2633
VarToken = "var-token"
2734
HeadingQuestion = "heading-question"
2835
PlaceholderSection = "placeholder-section"
2936
CUEFrontmatter = "cue-frontmatter"
37+
APMInputToken = "apm-input-token"
3038
)
3139

3240
// neutralText is the neutral replacement per body token.
3341
var neutralText = map[string]string{
3442
VarToken: "word",
3543
HeadingQuestion: "Placeholder",
3644
PlaceholderSection: "Placeholder Section",
45+
APMInputToken: "word",
3746
}
3847

3948
// IsKnown reports whether name is a recognized token name.
4049
func IsKnown(name string) bool {
4150
switch name {
42-
case VarToken, HeadingQuestion, PlaceholderSection, CUEFrontmatter:
51+
case VarToken, HeadingQuestion, PlaceholderSection, CUEFrontmatter, APMInputToken:
4352
return true
4453
}
4554
return false
@@ -72,6 +81,10 @@ func ContainsBodyToken(text string, tokens []string) bool {
7281
if strings.TrimSpace(text) == "..." {
7382
return true
7483
}
84+
case APMInputToken:
85+
if strings.Contains(text, apmInputPrefix) && apmInputRe.MatchString(text) {
86+
return true
87+
}
7588
}
7689
}
7790
return false
@@ -80,8 +93,8 @@ func ContainsBodyToken(text string, tokens []string) bool {
8093
// MaskBodyTokens replaces placeholder token patterns in text with neutral
8194
// content. cue-frontmatter is not a body token and is left unchanged.
8295
// 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.
96+
// entire text when they match; substring tokens (var-token, apm-input-token)
97+
// replace each occurrence in place.
8598
func MaskBodyTokens(text string, tokens []string) string {
8699
for _, tok := range tokens {
87100
switch tok {
@@ -96,6 +109,10 @@ func MaskBodyTokens(text string, tokens []string) string {
96109
if strings.TrimSpace(text) == "..." {
97110
return neutralText[PlaceholderSection]
98111
}
112+
case APMInputToken:
113+
if strings.Contains(text, apmInputPrefix) {
114+
text = apmInputRe.ReplaceAllLiteralString(text, neutralText[APMInputToken])
115+
}
99116
}
100117
}
101118
return text
@@ -129,6 +146,10 @@ func stripBodyTokens(text string, tokens []string) string {
129146
if strings.TrimSpace(text) == "..." {
130147
return ""
131148
}
149+
case APMInputToken:
150+
if strings.Contains(text, apmInputPrefix) {
151+
text = apmInputRe.ReplaceAllLiteralString(text, "")
152+
}
132153
}
133154
}
134155
return text

internal/placeholders/placeholders_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ func TestIsKnown(t *testing.T) {
1212
assert.True(t, placeholders.IsKnown(placeholders.HeadingQuestion))
1313
assert.True(t, placeholders.IsKnown(placeholders.PlaceholderSection))
1414
assert.True(t, placeholders.IsKnown(placeholders.CUEFrontmatter))
15+
assert.True(t, placeholders.IsKnown(placeholders.APMInputToken))
1516
assert.False(t, placeholders.IsKnown("unknown-token"))
1617
assert.False(t, placeholders.IsKnown(""))
1718
}
@@ -25,6 +26,7 @@ func TestValidate(t *testing.T) {
2526
placeholders.HeadingQuestion,
2627
placeholders.PlaceholderSection,
2728
placeholders.CUEFrontmatter,
29+
placeholders.APMInputToken,
2830
}))
2931
err := placeholders.Validate([]string{"bad-token"})
3032
assert.ErrorContains(t, err, `unknown placeholder token "bad-token"`)
@@ -292,3 +294,61 @@ func TestHasCUEFrontmatter(t *testing.T) {
292294
assert.False(t, placeholders.HasCUEFrontmatter(nil))
293295
assert.False(t, placeholders.HasCUEFrontmatter([]string{}))
294296
}
297+
298+
func TestAPMInputToken_IsKnown(t *testing.T) {
299+
assert.True(t, placeholders.IsKnown(placeholders.APMInputToken))
300+
}
301+
302+
func TestAPMInputToken_ContainsBodyToken(t *testing.T) {
303+
tok := []string{placeholders.APMInputToken}
304+
tests := []struct {
305+
name string
306+
text string
307+
want bool
308+
}{
309+
{"simple input param", "${input:pr_url}", true},
310+
{"hyphenated name", "${input:focus-area}", true},
311+
{"mixed case start", "${input:FocusArea}", true},
312+
{"embedded in sentence", "review ${input:pr_url} now", true},
313+
{"multiple tokens", "${input:a} and ${input:b}", true},
314+
{"non-matching form output", "${output:x}", false},
315+
{"no dollar", "{input:x}", false},
316+
{"space in name", "${input:bad name}", false},
317+
{"not input prefix", "${notinput:x}", false},
318+
{"empty text", "", false},
319+
{"plain text", "some prose", false},
320+
}
321+
for _, tt := range tests {
322+
t.Run(tt.name, func(t *testing.T) {
323+
assert.Equal(t, tt.want, placeholders.ContainsBodyToken(tt.text, tok))
324+
})
325+
}
326+
}
327+
328+
func TestAPMInputToken_MaskBodyTokens(t *testing.T) {
329+
tok := []string{placeholders.APMInputToken}
330+
tests := []struct {
331+
name string
332+
text string
333+
want string
334+
}{
335+
{"single token replaced", "${input:pr_url}", "word"},
336+
{"token in sentence", "review ${input:pr_url} now", "review word now"},
337+
{"two tokens replaced", "${input:a} and ${input:b}", "word and word"},
338+
{"malformed prefix unchanged", "${input:}", "${input:}"},
339+
{"non-matching unchanged", "${output:x}", "${output:x}"},
340+
{"plain text unchanged", "some prose", "some prose"},
341+
}
342+
for _, tt := range tests {
343+
t.Run(tt.name, func(t *testing.T) {
344+
assert.Equal(t, tt.want, placeholders.MaskBodyTokens(tt.text, tok))
345+
})
346+
}
347+
}
348+
349+
func TestAPMInputToken_IsAllBodyTokens(t *testing.T) {
350+
tok := []string{placeholders.APMInputToken}
351+
assert.True(t, placeholders.IsAllBodyTokens("${input:pr_url}", tok))
352+
assert.True(t, placeholders.IsAllBodyTokens("${input:a} ${input:b}", tok))
353+
assert.False(t, placeholders.IsAllBodyTokens("${input:a} extra", tok))
354+
}

internal/rules/MDS003-heading-increment/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ Heading levels should increment by one. No jumping from `#` to `###`.
4747
| -------------- | ---- | ------- | -------------------------------------------------------------------------------------------------------------------------- |
4848
| `placeholders` | list | `[]` | Placeholder tokens to treat as opaque; see [placeholder grammar](../../../docs/background/concepts/placeholder-grammar.md) |
4949

50-
Useful tokens: `heading-question`, `placeholder-section`, `var-token`.
50+
Useful tokens: `heading-question`, `placeholder-section`, `var-token`, `apm-input-token`.
5151

5252
## Config
5353

internal/rules/MDS004-first-line-heading/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ First line of the file should be a heading.
3636
| `level` | int | 1 | Required heading level for the first line |
3737
| `placeholders` | list | `[]` | Placeholder tokens to treat as opaque; see [placeholder grammar](../../../docs/background/concepts/placeholder-grammar.md) |
3838

39-
Useful tokens: `heading-question`, `var-token`, `placeholder-section`.
39+
Useful tokens: `heading-question`, `var-token`, `placeholder-section`, `apm-input-token`.
4040

4141
## Config
4242

internal/rules/MDS018-no-emphasis-as-heading/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Don't use bold or emphasis on a standalone line as a heading substitute.
3939
| -------------- | ---- | ------- | -------------------------------------------------------------------------------------------------------------------------- |
4040
| `placeholders` | list | `[]` | Placeholder tokens to treat as opaque; see [placeholder grammar](../../../docs/background/concepts/placeholder-grammar.md) |
4141

42-
Useful tokens: `var-token`, `heading-question`, `placeholder-section`.
42+
Useful tokens: `var-token`, `heading-question`, `placeholder-section`, `apm-input-token`.
4343

4444
## Config
4545

internal/rules/MDS023-paragraph-readability/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Paragraph readability index must not exceed a threshold.
2525
| `min-words` | int | 20 | Minimum word count to check a paragraph |
2626
| `placeholders` | list | `[]` | Placeholder tokens to treat as opaque; see [placeholder grammar](../../../docs/background/concepts/placeholder-grammar.md) |
2727

28-
Useful tokens: `var-token`, `heading-question`, `placeholder-section`.
28+
Useful tokens: `var-token`, `heading-question`, `placeholder-section`, `apm-input-token`.
2929

3030
Paragraphs with fewer words than `min-words` are skipped.
3131
Markdown tables and code blocks are skipped.

internal/rules/MDS024-paragraph-structure/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Paragraphs must not exceed sentence and word limits.
2525
| `max-words-per-sentence` | int | 40 | Maximum words per sentence |
2626
| `placeholders` | list | `[]` | Placeholder tokens to treat as opaque; see [placeholder grammar](../../../docs/background/concepts/placeholder-grammar.md) |
2727

28-
Useful tokens: `var-token`, `heading-question`, `placeholder-section`.
28+
Useful tokens: `var-token`, `heading-question`, `placeholder-section`, `apm-input-token`.
2929

3030
Markdown tables and code blocks are skipped.
3131

internal/rules/MDS027-cross-file-reference-integrity/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ Links to local files and heading anchors must resolve.
4444
| `wikilinks` | bool | `false` | Validate Obsidian-style `[[Page]]`, `[[Page#anchor]]`, `[[Page\|alias]]`, and `![[file.png]]` targets against the workspace. |
4545
| `wikilink-style` | string | `"obsidian"` | Resolution style for wikilinks. Only `obsidian` ships today; other values are rejected at config load. |
4646

47-
Useful tokens: `var-token`, `heading-question`, `placeholder-section`.
47+
Useful tokens: `var-token`, `heading-question`, `placeholder-section`, `apm-input-token`.
4848

4949
With `strict: false`, only Markdown targets (`.md`, `.markdown`)
5050
are checked (except images — see `links.validate-images`).

0 commit comments

Comments
 (0)