Skip to content

Commit 8d37e44

Browse files
jedudenclaude
andauthored
feat(catalog): add numeric: sort prefix for integer-shaped fields (#277)
`sort: numeric:id` parses each entry's value via strconv.Atoi and compares as integers, so mixed 2- and 3-digit plan IDs collate correctly (52, 61, …, 100, 132). Falls back to string compare when any entry's value fails to parse, so behavior stays predictable when one entry's field is missing or malformed. PLAN.md now uses the new mode. https://claude.ai/code/session_01JpY7sfVD2bzsuVa6vJRa13 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4c5f632 commit 8d37e44

5 files changed

Lines changed: 268 additions & 64 deletions

File tree

PLAN.md

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
glob:
55
- "plan/*.md"
66
- "!plan/proto.md"
7-
sort: id
7+
sort: numeric:id
88
header: |
99
1010
| ID | Status | Model | Title |
@@ -16,6 +16,24 @@ footer: |
1616

1717
| ID | Status | Model | Title |
1818
|-----|--------|--------|---------------------------------------------------------------------------------------------------------------------------|
19+
| 52 || | [Archetype / Template Library for Agentic Patterns](plan/52_archetype-template-library.md) |
20+
| 61 || | [Required Structure Rule Hardening](plan/61_required-structure-hardening.md) |
21+
| 65 || | [Spike WASM-Embedded Weasel Inference](plan/65_spike-wasm-embedded-inference.md) |
22+
| 78 || | [Query subcommand for front-matter filtering](plan/78_query-command.md) |
23+
| 83 || | [Security hardening batch](plan/83_security-hardening-batch.md) |
24+
| 84 || | [Symlink default-deny for file discovery](plan/84_symlink-default-deny.md) |
25+
| 85 || | [Increase test coverage to 95% by extracting shared rule helpers](plan/85_coverage-to-95-percent.md) |
26+
| 86 || | [Markdown flavor validation](plan/86_markdown-flavor-validation.md) |
27+
| 89 || | [TOC generator directive and MDS035 auto-fix](plan/89_toc-generator-directive.md) |
28+
| 90 || | [Isolate corpus test git config from host signing](plan/90_corpus-test-git-config-isolation.md) |
29+
| 91 || | [MDS037 skips paragraphs inside generated sections](plan/91_mds037-skip-generated-sections.md) |
30+
| 92 || sonnet | [File kinds — config schema, assignment, merge](plan/92_file-kinds.md) |
31+
| 93 || sonnet | [Placeholder grammar — opt-in token vocabulary](plan/93_placeholder-grammar.md) |
32+
| 94 || sonnet | [Lint-once for `<?include?>` and `<?catalog?>` embeds](plan/94_lint-once-for-embeds.md) |
33+
| 95 || opus | [Kind/rule resolution observability via `kinds` subcommand](plan/95_kind-rule-resolution-cli.md) |
34+
| 96 || sonnet | [Adopt kinds in mdsmith repo and ship the docs](plan/96_kinds-adoption-and-docs.md) |
35+
| 97 || opus | [Deep-merge for kinds and overrides](plan/97_deep-merge-config.md) |
36+
| 98 || sonnet | [Replace `archetypes` with `kinds`](plan/98_replace-archetypes-with-kinds.md) |
1937
| 100 || sonnet | [build config block and MDS040 recipe-safety rule](plan/100_build-config-and-mds040.md) |
2038
| 101 || sonnet | [build directive and MDS039 lint rule](plan/101_build-directive-mds039.md) |
2139
| 102 | 🔲 | opus | [Builder interface and mdsmith build subcommand](plan/102_build-subcommand.md) |
@@ -54,7 +72,7 @@ footer: |
5472
| 140 || sonnet | [Per-kind `path-pattern` for filename validation](plan/140_kind-path-pattern.md) |
5573
| 142 | 🔲 | sonnet | [Content rules for prose constraints](plan/142_schema-content-constraints.md) |
5674
| 143 || sonnet | [Schema cross-references, acronyms, and index](plan/143_schema-cross-refs-acronyms-index.md) |
57-
| 144 | 🔲 | sonnet | [Numeric sort for `<?catalog?>` directive](plan/144_catalog-numeric-sort.md) |
75+
| 144 | | sonnet | [Numeric sort for `<?catalog?>` directive](plan/144_catalog-numeric-sort.md) |
5876
| 145 | 🔲 | opus | [Publish mdsmith via asdf and mise registry submissions](plan/145_asdf-mise-registry-submissions.md) |
5977
| 146 || opus | [Schema engine — sources, scope tree, per-scope rules](plan/146_inline-schema-in-kinds.md) |
6078
| 147 | 🔲 | opus | [Actionable schema diagnostics for MDS020](plan/147_actionable-schema-diagnostics.md) |
@@ -66,22 +84,4 @@ footer: |
6684
| 153 | 🔲 | opus | [Unify linkgraph and the LSP symbol index](plan/153_unify-linkgraph-and-lsp-index.md) |
6785
| 154 | 🔲 | sonnet | [arch-fix: extract cross-rule helpers](plan/154_arch-fix-rule-helper-extraction.md) |
6886
| 155 | 🔲 | sonnet | [arch-fix: relocate convention types out of markdownflavor](plan/155_arch-fix-convention-config-ownership.md) |
69-
| 52 || | [Archetype / Template Library for Agentic Patterns](plan/52_archetype-template-library.md) |
70-
| 61 || | [Required Structure Rule Hardening](plan/61_required-structure-hardening.md) |
71-
| 65 || | [Spike WASM-Embedded Weasel Inference](plan/65_spike-wasm-embedded-inference.md) |
72-
| 78 || | [Query subcommand for front-matter filtering](plan/78_query-command.md) |
73-
| 83 || | [Security hardening batch](plan/83_security-hardening-batch.md) |
74-
| 84 || | [Symlink default-deny for file discovery](plan/84_symlink-default-deny.md) |
75-
| 85 || | [Increase test coverage to 95% by extracting shared rule helpers](plan/85_coverage-to-95-percent.md) |
76-
| 86 || | [Markdown flavor validation](plan/86_markdown-flavor-validation.md) |
77-
| 89 || | [TOC generator directive and MDS035 auto-fix](plan/89_toc-generator-directive.md) |
78-
| 90 || | [Isolate corpus test git config from host signing](plan/90_corpus-test-git-config-isolation.md) |
79-
| 91 || | [MDS037 skips paragraphs inside generated sections](plan/91_mds037-skip-generated-sections.md) |
80-
| 92 || sonnet | [File kinds — config schema, assignment, merge](plan/92_file-kinds.md) |
81-
| 93 || sonnet | [Placeholder grammar — opt-in token vocabulary](plan/93_placeholder-grammar.md) |
82-
| 94 || sonnet | [Lint-once for `<?include?>` and `<?catalog?>` embeds](plan/94_lint-once-for-embeds.md) |
83-
| 95 || opus | [Kind/rule resolution observability via `kinds` subcommand](plan/95_kind-rule-resolution-cli.md) |
84-
| 96 || sonnet | [Adopt kinds in mdsmith repo and ship the docs](plan/96_kinds-adoption-and-docs.md) |
85-
| 97 || opus | [Deep-merge for kinds and overrides](plan/97_deep-merge-config.md) |
86-
| 98 || sonnet | [Replace `archetypes` with `kinds`](plan/98_replace-archetypes-with-kinds.md) |
8787
<?/catalog?>

docs/guides/directives/generating-content.md

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,30 @@ gitignore: "false"
109109

110110
### Sorting
111111

112-
Format: `[-]KEY`. A `-` prefix means descending.
113-
Built-in keys: `path`, `filename`. Any other key is
114-
looked up in front matter. Missing values sort as
115-
empty string.
112+
Format: `[-][numeric:]KEY`. A `-` prefix means
113+
descending. Built-in keys: `path`, `filename`. Any
114+
other key is looked up in front matter. Missing
115+
values sort as empty string.
116+
117+
The optional `numeric:` prefix opts a field into
118+
integer comparison. Use it for ID-shaped fields
119+
where mixed 2-digit and 3-digit values would
120+
otherwise collate lexicographically (`100` before
121+
`52`):
122+
123+
```markdown
124+
<?catalog
125+
glob: "plan/*.md"
126+
sort: numeric:id
127+
row: "| {id} | [{title}]({filename}) |"
128+
?>
129+
```
130+
131+
If any matched file's value fails to parse as an
132+
integer, the directive falls back to string
133+
compare for all entries — no error is raised, so a
134+
field that is sometimes numeric stays usable.
135+
`-numeric:id` reverses the order.
116136

117137
### What happens when no files match
118138

internal/rules/catalog/rule.go

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"path"
77
"path/filepath"
88
"sort"
9+
"strconv"
910
"strings"
1011
"sync"
1112

@@ -19,6 +20,11 @@ import (
1920
"github.com/jeduden/mdsmith/internal/yamlutil"
2021
)
2122

23+
// numericSortPrefix marks a sort spec whose key value should be
24+
// parsed as an integer before comparison. The prefix follows any
25+
// leading "-" descending marker — `-numeric:id`, not `numeric:-id`.
26+
const numericSortPrefix = "numeric:"
27+
2228
func init() {
2329
rule.Register(&Rule{})
2430
}
@@ -233,6 +239,7 @@ func validateSort(filePath string, line int, sortVal string) []lint.Diagnostic {
233239
`generated section directive has empty "sort" value`)}
234240
}
235241
key := strings.TrimPrefix(sortVal, "-")
242+
key = strings.TrimPrefix(key, numericSortPrefix)
236243
if key == "" {
237244
return []lint.Diagnostic{makeDiag(filePath, line,
238245
fmt.Sprintf("generated section directive has invalid sort value %q", sortVal))}
@@ -308,7 +315,7 @@ func buildCatalogEntries(
308315
globFS, prefix := resolveGlobFS(f, params)
309316
files := resolveGlobMatchesFrom(globFS, f, params)
310317

311-
sortKey, descending := parseSort(params)
318+
sortKey, descending, numeric := parseSort(params)
312319
_, hasRow := params["row"]
313320
needFM := hasRow || (sortKey != "path" && sortKey != "filename")
314321

@@ -334,7 +341,7 @@ func buildCatalogEntries(
334341
entries = append(entries, fileEntry{fields: fields})
335342
}
336343

337-
sortEntries(entries, sortKey, descending)
344+
sortEntries(entries, sortKey, descending, numeric)
338345
return entries, diags
339346
}
340347

@@ -484,26 +491,53 @@ func renderCatalogContent(
484491
return renderTemplate(params, entries, cols)
485492
}
486493

487-
// parseSort parses the sort value from params, returning the key and direction.
488-
func parseSort(params map[string]string) (key string, descending bool) {
494+
// parseSort parses the sort value from params, returning the key,
495+
// direction, and whether the value should be compared numerically.
496+
// The `numeric:` prefix opts into integer comparison and may follow
497+
// the descending `-` marker, e.g. `-numeric:id`.
498+
func parseSort(params map[string]string) (key string, descending, numeric bool) {
489499
sortVal, ok := params["sort"]
490500
if !ok || sortVal == "" {
491-
return "path", false
501+
return "path", false, false
492502
}
493503

494504
if strings.HasPrefix(sortVal, "-") {
495-
return sortVal[1:], true
505+
descending = true
506+
sortVal = sortVal[1:]
507+
}
508+
if strings.HasPrefix(sortVal, numericSortPrefix) {
509+
numeric = true
510+
sortVal = sortVal[len(numericSortPrefix):]
496511
}
497-
return sortVal, false
512+
return sortVal, descending, numeric
498513
}
499514

500-
// sortEntries sorts file entries by the given key.
501-
func sortEntries(entries []fileEntry, key string, descending bool) {
502-
sort.SliceStable(entries, func(i, j int) bool {
503-
vi := sortValue(entries[i], key)
504-
vj := sortValue(entries[j], key)
515+
// sortEntries sorts file entries by the given key. When numeric is
516+
// true and every entry's value parses as an int, entries are ordered
517+
// by the integer value; any parse failure falls back to string
518+
// compare for the whole sort so behavior stays predictable when one
519+
// entry's field is missing or malformed.
520+
func sortEntries(entries []fileEntry, key string, descending, numeric bool) {
521+
useInts := numeric && allParseAsInt(entries, key)
505522

506-
cmp := strings.Compare(strings.ToLower(vi), strings.ToLower(vj))
523+
sort.SliceStable(entries, func(i, j int) bool {
524+
var cmp int
525+
if useInts {
526+
// Re-parse from the current entry rather than a fixed
527+
// index — SliceStable reorders the slice during sort.
528+
ni, _ := parseSortInt(entries[i], key)
529+
nj, _ := parseSortInt(entries[j], key)
530+
switch {
531+
case ni < nj:
532+
cmp = -1
533+
case ni > nj:
534+
cmp = 1
535+
}
536+
} else {
537+
vi := sortValue(entries[i], key)
538+
vj := sortValue(entries[j], key)
539+
cmp = strings.Compare(strings.ToLower(vi), strings.ToLower(vj))
540+
}
507541
if cmp == 0 {
508542
// Tiebreaker: path ascending, case-insensitive.
509543
pi := strings.ToLower(fieldinterp.Stringify(entries[i].fields["filename"]))
@@ -518,6 +552,24 @@ func sortEntries(entries []fileEntry, key string, descending bool) {
518552
})
519553
}
520554

555+
// allParseAsInt reports whether every entry's value for key parses
556+
// as an integer. Used to decide whether numeric mode applies before
557+
// sorting begins.
558+
func allParseAsInt(entries []fileEntry, key string) bool {
559+
for _, e := range entries {
560+
if _, err := parseSortInt(e, key); err != nil {
561+
return false
562+
}
563+
}
564+
return true
565+
}
566+
567+
// parseSortInt extracts the entry's sort key as a trimmed string
568+
// and parses it via strconv.Atoi.
569+
func parseSortInt(entry fileEntry, key string) (int, error) {
570+
return strconv.Atoi(strings.TrimSpace(sortValue(entry, key)))
571+
}
572+
521573
// sortValue returns the sort value for a file entry given a key.
522574
func sortValue(entry fileEntry, key string) string {
523575
switch key {

0 commit comments

Comments
 (0)