Skip to content

Commit b04acfe

Browse files
author
merge-queue-bot
committed
Merge PR #568: plan-102: multi-output <?build?> directive
2 parents c6c1e60 + eb12af4 commit b04acfe

7 files changed

Lines changed: 199 additions & 18 deletions

File tree

internal/index/build_coverage_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,63 @@ func TestAbsToWorkspace_RelOutsideRoot(t *testing.T) {
173173
got := absToWorkspace("/root", "/elsewhere/x.md")
174174
assert.Equal(t, "/elsewhere/x.md", got)
175175
}
176+
177+
// TestCollectDirectiveEdges_BuildInputs covers the DirectiveBuild
178+
// branches in collectDirectiveEdges:
179+
// - a glob `inputs:` entry (d.IsUnresolved() == true) emits an
180+
// EdgeBuild with Unresolved=true
181+
// - a literal `inputs:` entry emits a resolved EdgeBuild with
182+
// TargetFile set
183+
// - an absolute path (ResolveRelTarget returns "") is skipped
184+
func TestCollectDirectiveEdges_BuildInputs(t *testing.T) {
185+
t.Parallel()
186+
// The build directive has three inputs (values must be quoted so
187+
// the YAML parser surfaced them as strings via ValidateStringParams):
188+
// - "**/*.md" → glob → unresolved edge
189+
// - "src.svg" → literal path → resolved edge (target: dir/src.svg)
190+
// - "/abs.svg" → absolute → ResolveRelTarget returns "" → skipped
191+
src := []byte(
192+
"# T\n\n" +
193+
"<?build\n" +
194+
"recipe: render\n" +
195+
"outputs:\n" +
196+
" - \"out.png\"\n" +
197+
"inputs:\n" +
198+
" - \"**/*.md\"\n" +
199+
" - \"src.svg\"\n" +
200+
" - \"/abs.svg\"\n" +
201+
"?>\n" +
202+
"- [out.png](out.png)\n" +
203+
"<?/build?>\n",
204+
)
205+
fe := buildFileEntry("dir/doc.md", src)
206+
require.NotNil(t, fe)
207+
208+
// Collect only EdgeBuild edges.
209+
var buildEdges []Edge
210+
for _, e := range fe.Outgoing {
211+
if e.Kind == EdgeBuild {
212+
buildEdges = append(buildEdges, e)
213+
}
214+
}
215+
// Expect exactly two build edges: the glob (unresolved) and the
216+
// literal path (resolved). The absolute path is skipped.
217+
require.Len(t, buildEdges, 2)
218+
219+
// Find the unresolved and resolved edges (order may vary).
220+
var unresolved, resolved *Edge
221+
for i := range buildEdges {
222+
if buildEdges[i].Unresolved {
223+
unresolved = &buildEdges[i]
224+
} else {
225+
resolved = &buildEdges[i]
226+
}
227+
}
228+
require.NotNil(t, unresolved, "expected an unresolved build edge for the glob input")
229+
assert.Equal(t, "dir/doc.md", unresolved.SourceFile)
230+
assert.Empty(t, unresolved.TargetFile)
231+
232+
require.NotNil(t, resolved, "expected a resolved build edge for src.svg")
233+
assert.Equal(t, "dir/doc.md", resolved.SourceFile)
234+
assert.Equal(t, "dir/src.svg", resolved.TargetFile)
235+
}

internal/index/locate.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,7 @@ func piToLocate(pi *piparser.ProcessingInstruction, source []byte, lines [][]byt
439439
if key := enclosingListKey(lines, line); key != "" {
440440
res.DirectiveArg = key
441441
res.DirectiveValue = item
442-
if pi.Name == "build" && key == "inputs" {
442+
if pi.Name == "build" && key == "inputs" && !isGlobPattern(item) {
443443
res.DirectiveTargetFile = item
444444
}
445445
}
@@ -484,6 +484,9 @@ var piArgRE = regexp.MustCompile(`^\s*([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*?)\s*$`
484484
// keeps its quote characters; the caller strips them.
485485
var piListItemRE = regexp.MustCompile(`^\s*-\s+(.*?)\s*$`)
486486

487+
// isGlobPattern reports whether p contains doublestar glob metacharacters.
488+
func isGlobPattern(p string) bool { return strings.ContainsAny(p, "*?[{") }
489+
487490
// headingOnLine returns the heading whose first source line equals
488491
// line, or nil.
489492
func headingOnLine(root ast.Node, source []byte, line int) *ast.Heading {

internal/index/locate_coverage_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,3 +216,68 @@ func TestEnclosingListKeyNoPrecedingKey(t *testing.T) {
216216
}
217217
assert.Empty(t, enclosingListKey(lines, 2))
218218
}
219+
220+
func TestEnclosingListKey_MultipleListItemsThenKey(t *testing.T) {
221+
t.Parallel()
222+
lines := [][]byte{
223+
[]byte("inputs:"),
224+
[]byte(" - a.md"),
225+
[]byte(" - b.md"),
226+
}
227+
got := enclosingListKey(lines, 3)
228+
assert.Equal(t, "inputs", got)
229+
}
230+
231+
func TestEnclosingListKey_EmptyLineSkipped(t *testing.T) {
232+
t.Parallel()
233+
lines := [][]byte{
234+
[]byte("inputs:"),
235+
[]byte(""),
236+
[]byte(" - x"),
237+
}
238+
got := enclosingListKey(lines, 3)
239+
assert.Equal(t, "inputs", got)
240+
}
241+
242+
func TestEnclosingListKey_NonListNonKeyLine(t *testing.T) {
243+
t.Parallel()
244+
lines := [][]byte{
245+
[]byte("some prose"),
246+
[]byte(" - item"),
247+
}
248+
got := enclosingListKey(lines, 2)
249+
assert.Empty(t, got)
250+
}
251+
252+
// TestLocateBuildDirectiveInputsGlob: glob inputs must not set DirectiveTargetFile.
253+
func TestLocateBuildDirectiveInputsGlob(t *testing.T) {
254+
t.Parallel()
255+
src := "# T\n\n<?build\nrecipe: render\noutputs:\n - \"out.png\"\ninputs:\n - \"**/*.md\"\n?>\n<?/build?>\n"
256+
res := Locator{Path: "a.md"}.Locate([]byte(src), 8, 5)
257+
assert.Equal(t, TokenDirectiveArg, res.Tag)
258+
assert.Equal(t, "build", res.DirectiveName)
259+
assert.Equal(t, "inputs", res.DirectiveArg)
260+
assert.Empty(t, res.DirectiveTargetFile, "glob input must not set DirectiveTargetFile")
261+
}
262+
263+
// TestLocateBuildDirectiveInputsLiteral: literal inputs set DirectiveTargetFile.
264+
func TestLocateBuildDirectiveInputsLiteral(t *testing.T) {
265+
t.Parallel()
266+
src := "# T\n\n<?build\nrecipe: render\noutputs:\n - \"out.png\"\ninputs:\n - \"src.svg\"\n?>\n<?/build?>\n"
267+
res := Locator{Path: "a.md"}.Locate([]byte(src), 8, 5)
268+
assert.Equal(t, TokenDirectiveArg, res.Tag)
269+
assert.Equal(t, "build", res.DirectiveName)
270+
assert.Equal(t, "inputs", res.DirectiveArg)
271+
assert.Equal(t, "src.svg", res.DirectiveTargetFile)
272+
}
273+
274+
// TestLocateBuildDirectiveInputsBraceGlob: brace-expansion inputs must not set DirectiveTargetFile.
275+
func TestLocateBuildDirectiveInputsBraceGlob(t *testing.T) {
276+
t.Parallel()
277+
src := "# T\n\n<?build\nrecipe: render\noutputs:\n - \"out.png\"\ninputs:\n - \"{a,b}.md\"\n?>\n<?/build?>\n"
278+
res := Locator{Path: "a.md"}.Locate([]byte(src), 8, 5)
279+
assert.Equal(t, TokenDirectiveArg, res.Tag)
280+
assert.Equal(t, "build", res.DirectiveName)
281+
assert.Equal(t, "inputs", res.DirectiveArg)
282+
assert.Empty(t, res.DirectiveTargetFile, "brace-expansion input must not set DirectiveTargetFile")
283+
}

internal/rules/build/rule.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -296,8 +296,8 @@ func (r *Rule) generateBody(
296296
rendered := make([]string, 0, len(outputs))
297297
for _, output := range outputs {
298298
alt := fmt.Sprintf("%s output: %s", recipeName, output)
299-
rendered = append(rendered,
300-
strings.NewReplacer("{alt}", alt, "{output}", output).Replace(tmpl))
299+
body := strings.NewReplacer("{output}", output, "{alt}", alt).Replace(tmpl)
300+
rendered = append(rendered, body)
301301
}
302302
body := strings.Join(rendered, "\n")
303303

@@ -369,11 +369,11 @@ func validatePathEntry(p string, allowGlob bool) string {
369369
if strings.HasPrefix(p, "/") || strings.HasPrefix(p, "~") {
370370
return "must be a relative path"
371371
}
372-
if !allowGlob && strings.ContainsAny(p, "*?[") {
372+
if !allowGlob && strings.ContainsAny(p, "*?[{") {
373373
return "must not contain glob characters"
374374
}
375375
cleaned := path.Clean(p)
376-
if cleaned == ".." || strings.HasPrefix(cleaned, "../") {
376+
if cleaned == ".." || cleaned == "." || strings.HasPrefix(cleaned, "../") {
377377
return `must not contain ".." path components`
378378
}
379379
if underMdsmithDir(cleaned) {

internal/rules/build/rule_test.go

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -699,7 +699,6 @@ func TestValidatePathEntry_UNC(t *testing.T) {
699699
}
700700

701701
func TestValidatePathEntry_NTFSADS(t *testing.T) {
702-
// foo:bar — NTFS alternate data stream syntax.
703702
assert.NotEmpty(t, validatePathEntry("foo:bar", false))
704703
assert.NotEmpty(t, validatePathEntry("dir/foo:bar.txt", false))
705704
}
@@ -714,7 +713,6 @@ func TestValidatePathEntry_ReservedDeviceNames(t *testing.T) {
714713
}
715714

716715
func TestValidatePathEntry_ReservedDeviceNames_NotMatchedAsSubstring(t *testing.T) {
717-
// CONSOLE / NULLABLE are not reserved device names.
718716
for _, p := range []string{"CONSOLE.md", "NULLABLE", "COMPANY", "LPT10"} {
719717
assert.Empty(t, validatePathEntry(p, false), "path %q should be accepted", p)
720718
}
@@ -733,34 +731,34 @@ func TestValidatePathEntry_Tilde(t *testing.T) {
733731
}
734732

735733
func TestValidatePathEntry_DotDot(t *testing.T) {
736-
// A path that escapes root after Clean (or is "..") is rejected.
737734
for _, p := range []string{"../out.png", "..", "a/../../b.png"} {
738735
assert.NotEmpty(t, validatePathEntry(p, false), "path %q should be rejected", p)
739736
}
740737
}
741738

742739
func TestValidatePathEntry_InteriorDotDotThatCleansInBounds(t *testing.T) {
743-
// Per the plan's path-shape rule, the check is on the result of
744-
// path.Clean: "a/../b.png" cleans to "b.png", which stays in-root,
745-
// so it is accepted.
740+
// "a/../b.png" cleans to "b.png" (stays in-root), so it is accepted.
746741
assert.Empty(t, validatePathEntry("a/../b.png", false))
747742
}
748743

744+
func TestValidatePathEntry_DotDotCollapsesToRoot(t *testing.T) {
745+
// "a/.." cleans to "." (workspace root) — reject to prevent artifacts at ".".
746+
assert.NotEmpty(t, validatePathEntry("a/..", false))
747+
}
748+
749749
func TestValidatePathEntry_UnderMdsmithDir(t *testing.T) {
750750
for _, p := range []string{".mdsmith/state", ".mdsmith/out.png"} {
751751
assert.NotEmpty(t, validatePathEntry(p, false), "path %q should be rejected", p)
752752
}
753753
}
754754

755755
func TestValidatePathEntry_OutputsRejectGlobChars(t *testing.T) {
756-
// allowGlob=false: glob meta-characters are rejected.
757-
for _, p := range []string{"out*.png", "out?.png", "out[1].png"} {
756+
for _, p := range []string{"out*.png", "out?.png", "out[1].png", "out{a,b}.png"} {
758757
assert.NotEmpty(t, validatePathEntry(p, false), "path %q should be rejected for outputs", p)
759758
}
760759
}
761760

762761
func TestValidatePathEntry_InputsAcceptGlobChars(t *testing.T) {
763-
// allowGlob=true: doublestar globs are accepted.
764762
for _, p := range []string{
765763
"src/*.md", "**/*.md", "chapters/[0-9]*.md", "a?b.md", "{a,b}.md",
766764
} {

internal/rules/recipesafety/rule.go

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -268,8 +268,7 @@ func hasDotDotSegment(p string) bool {
268268
func (r *Rule) checkTokens(filePath, name string, tokens []string) []lint.Diagnostic {
269269
var diags []lint.Diagnostic
270270
for _, tok := range tokens {
271-
isSinglePlaceholder := placeholderRe.MatchString(tok) &&
272-
placeholderRe.FindString(tok) == tok
271+
isSinglePlaceholder := placeholderRe.FindString(tok) == tok
273272
if !isSinglePlaceholder {
274273
for _, op := range shellOperators {
275274
if strings.Contains(tok, op) {
@@ -280,16 +279,42 @@ func (r *Rule) checkTokens(filePath, name string, tokens []string) []lint.Diagno
280279
}
281280
}
282281
}
283-
if fusedRe.MatchString(tok) {
284-
fused := fusedRe.FindString(tok)
282+
if fused := fusedRe.FindString(tok); fused != "" {
285283
diags = append(diags, r.diag(filePath, lint.Error,
286284
fmt.Sprintf("recipe %q: command contains fused placeholders %q — separate with a delimiter",
287285
name, fused)))
286+
} else {
287+
diags = append(diags, r.checkReservedTokenShape(filePath, name, tok)...)
288288
}
289289
}
290290
return diags
291291
}
292292

293+
// checkReservedTokenShape reports an error when a reserved collective
294+
// placeholder ({outputs} or {inputs}) is embedded in a larger argv
295+
// token rather than standing alone — list-expanding a fragment of a
296+
// token has no well-defined semantics.
297+
func (r *Rule) checkReservedTokenShape(filePath, name, tok string) []lint.Diagnostic {
298+
var diags []lint.Diagnostic
299+
for _, m := range placeholderRe.FindAllStringSubmatch(tok, -1) {
300+
if isReservedParamName(m[1]) && tok != m[0] {
301+
diags = append(diags, r.diag(filePath, lint.Error,
302+
fmt.Sprintf("recipe %q: reserved placeholder %q must be a standalone argv token, not embedded in %q",
303+
name, m[0], tok)))
304+
}
305+
}
306+
return diags
307+
}
308+
309+
func isReservedParamName(name string) bool {
310+
for _, r := range reservedParamNames {
311+
if name == r {
312+
return true
313+
}
314+
}
315+
return false
316+
}
317+
293318
func (r *Rule) checkUnusedParams(filePath, name string, rec recipe) []lint.Diagnostic {
294319
declared := make(map[string]bool)
295320
for _, p := range rec.Required {

internal/rules/recipesafety/rule_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,36 @@ func TestCheck_MultipleRecipes_SortedByName(t *testing.T) {
644644
assert.Contains(t, diags[1].Message, `"z-recipe"`)
645645
}
646646

647+
// --- checkReservedTokenShape ---
648+
649+
func TestCheck_EmbeddedReservedPlaceholder_Outputs(t *testing.T) {
650+
t.Parallel()
651+
// {outputs} embedded inside a larger token — must be standalone.
652+
r := newRule(map[string]recipe{"x": {Command: "tool --out={outputs}"}})
653+
diags := r.Check(newFile(t, "f.md"))
654+
require.Len(t, diags, 1)
655+
assert.Equal(t, lint.Error, diags[0].Severity)
656+
assert.Contains(t, diags[0].Message, `reserved placeholder "{outputs}" must be a standalone argv token`)
657+
}
658+
659+
func TestCheck_EmbeddedReservedPlaceholder_Inputs(t *testing.T) {
660+
t.Parallel()
661+
// {inputs} embedded inside a larger token — must be standalone.
662+
r := newRule(map[string]recipe{"x": {Command: "tool --in={inputs}"}})
663+
diags := r.Check(newFile(t, "f.md"))
664+
require.Len(t, diags, 1)
665+
assert.Equal(t, lint.Error, diags[0].Severity)
666+
assert.Contains(t, diags[0].Message, `reserved placeholder "{inputs}" must be a standalone argv token`)
667+
}
668+
669+
func TestCheck_StandaloneReservedPlaceholder_NoDiagnostic(t *testing.T) {
670+
t.Parallel()
671+
// {outputs} as a standalone token is correct — no reserved-shape error.
672+
r := newRule(map[string]recipe{"x": {Command: "tool {outputs} -o {inputs}"}})
673+
diags := r.Check(newFile(t, "f.md"))
674+
assert.Empty(t, diags)
675+
}
676+
647677
// --- Diagnostic fields ---
648678

649679
func TestCheck_DiagnosticFields(t *testing.T) {

0 commit comments

Comments
 (0)