remlint 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +328 -0
  3. data/config/default.yml +374 -0
  4. data/docs/remlint-rules.md +390 -0
  5. data/docs/remlint.md +238 -0
  6. data/exe/remlint +8 -0
  7. data/lib/remlint/cli.rb +300 -0
  8. data/lib/remlint/command.rb +229 -0
  9. data/lib/remlint/config.rb +255 -0
  10. data/lib/remlint/date_literal.rb +283 -0
  11. data/lib/remlint/document.rb +161 -0
  12. data/lib/remlint/expr_lexer.rb +197 -0
  13. data/lib/remlint/extractors.rb +210 -0
  14. data/lib/remlint/formatter.rb +96 -0
  15. data/lib/remlint/invocation.rb +145 -0
  16. data/lib/remlint/logical_line.rb +194 -0
  17. data/lib/remlint/offense.rb +117 -0
  18. data/lib/remlint/rule.rb +216 -0
  19. data/lib/remlint/rules/addomit_without_scanfrom.rb +189 -0
  20. data/lib/remlint/rules/advance_warning_body.rb +176 -0
  21. data/lib/remlint/rules/banner_placement.rb +126 -0
  22. data/lib/remlint/rules/calendar_text_limited.rb +125 -0
  23. data/lib/remlint/rules/callback_signature.rb +221 -0
  24. data/lib/remlint/rules/clause_needs_full_date.rb +176 -0
  25. data/lib/remlint/rules/clause_requires_at.rb +178 -0
  26. data/lib/remlint/rules/clause_value_range.rb +257 -0
  27. data/lib/remlint/rules/color_component_range.rb +295 -0
  28. data/lib/remlint/rules/coordinate_not_string.rb +175 -0
  29. data/lib/remlint/rules/dangling_continuation.rb +134 -0
  30. data/lib/remlint/rules/date_out_of_range.rb +180 -0
  31. data/lib/remlint/rules/debug_command.rb +189 -0
  32. data/lib/remlint/rules/easterdate_from_today.rb +160 -0
  33. data/lib/remlint/rules/function_arity.rb +385 -0
  34. data/lib/remlint/rules/function_redefinition.rb +159 -0
  35. data/lib/remlint/rules/generated_file_edited.rb +125 -0
  36. data/lib/remlint/rules/hebrew_date.rb +245 -0
  37. data/lib/remlint/rules/iftrig_with_satisfy.rb +101 -0
  38. data/lib/remlint/rules/include_path.rb +155 -0
  39. data/lib/remlint/rules/info_clause.rb +186 -0
  40. data/lib/remlint/rules/info_substitution_without_header.rb +169 -0
  41. data/lib/remlint/rules/invocation_mismatch.rb +223 -0
  42. data/lib/remlint/rules/keyword_case.rb +172 -0
  43. data/lib/remlint/rules/license_header.rb +101 -0
  44. data/lib/remlint/rules/line_length.rb +101 -0
  45. data/lib/remlint/rules/literal_type_mismatch.rb +324 -0
  46. data/lib/remlint/rules/localization_pack.rb +144 -0
  47. data/lib/remlint/rules/moon_phase_argument.rb +162 -0
  48. data/lib/remlint/rules/omit_aware_delta.rb +168 -0
  49. data/lib/remlint/rules/push_vars_missing_name.rb +158 -0
  50. data/lib/remlint/rules/repeat_trigger.rb +188 -0
  51. data/lib/remlint/rules/satisfy_constraint.rb +230 -0
  52. data/lib/remlint/rules/shell_maxlen.rb +170 -0
  53. data/lib/remlint/rules/shell_use_while_run_disabled.rb +172 -0
  54. data/lib/remlint/rules/string_escape.rb +158 -0
  55. data/lib/remlint/rules/syntax.rb +229 -0
  56. data/lib/remlint/rules/system_variable_assignment.rb +197 -0
  57. data/lib/remlint/rules/tag_syntax.rb +145 -0
  58. data/lib/remlint/rules/text_after_eof_marker.rb +134 -0
  59. data/lib/remlint/rules/time_zone_name.rb +212 -0
  60. data/lib/remlint/rules/tk_tag_namespace.rb +121 -0
  61. data/lib/remlint/rules/todo_complete_through.rb +122 -0
  62. data/lib/remlint/rules/trailing_whitespace.rb +116 -0
  63. data/lib/remlint/rules/translate_command.rb +203 -0
  64. data/lib/remlint/rules/unbalanced_blocks.rb +305 -0
  65. data/lib/remlint/rules/unbalanced_delimiters.rb +251 -0
  66. data/lib/remlint/rules/unknown_special_type.rb +155 -0
  67. data/lib/remlint/rules/unknown_substitution_sequence.rb +255 -0
  68. data/lib/remlint/rules/unknown_system_variable.rb +145 -0
  69. data/lib/remlint/rules/unquoted_shell_substitution.rb +266 -0
  70. data/lib/remlint/rules/until_before_from.rb +204 -0
  71. data/lib/remlint/rules/world_writable_script.rb +128 -0
  72. data/lib/remlint/rules.rb +60 -0
  73. data/lib/remlint/runner.rb +274 -0
  74. data/lib/remlint/source.rb +36 -0
  75. data/lib/remlint/tables.rb +395 -0
  76. data/lib/remlint/trigger.rb +359 -0
  77. data/lib/remlint/version.rb +5 -0
  78. data/lib/remlint/vocabulary.rb +236 -0
  79. data/lib/remlint.rb +35 -0
  80. data/tasks/generate_tables.rb +175 -0
  81. metadata +170 -0
@@ -0,0 +1,390 @@
1
+ # Rule backlog
2
+
3
+ The 100 rule ideas from `book-sections/*.md`, triaged. Ordered by what I would
4
+ build next, not by chapter.
5
+
6
+ The test each rule has to pass: **does Remind stay silent when this is wrong?**
7
+ Remind reports `Missing ']'`, `E_NOSUCH_VAR` and `Can't compute trigger`
8
+ perfectly well on its own. A linter earns its place only where Remind says
9
+ nothing, or says it months later on the day a reminder fires.
10
+
11
+ Constants are verified against the C source, not taken from the prose. Where a
12
+ rule below cites a limit, the file and line that defines it is named.
13
+
14
+ ---
15
+
16
+ ## Tier 1 — security (2) — DONE
17
+
18
+ Built as one rule, `UnquotedShellSubstitution`: it is the same check on two
19
+ commands, and one message serves both.
20
+
21
+ The only rules in the set where being wrong is a hole rather than a wrong date.
22
+
23
+ - [x] **RunBodyUnquotedSubstitution** — a `%` sequence or `[expr]` outside shell
24
+ quotes in a `RUN` body. Reminder text is data; unquoted data in a shell
25
+ command is command injection in a calendar.
26
+ - [x] **IncludeCmdUnquotedPaste** — the same for `INCLUDECMD`. `examples/astro`
27
+ writes `L="[lessons]"` carefully; nothing enforces that care today.
28
+
29
+ ## Tier 2 — exact, single-file, silent when wrong (10) — DONE
30
+
31
+ Closed sets and literal comparisons. No cross-file knowledge, no runtime values.
32
+
33
+ Four of these turned out narrower than the book description implied, and every
34
+ narrowing came from the source, the man page or the corpus rather than from
35
+ reasoning about it. They are recorded inline below and in the rules' own doc
36
+ comments.
37
+
38
+ Built as ten rule classes, because two pairs are one check each:
39
+ `UnquotedShellSubstitution`, `ClauseRequiresAt`, `IftrigWithSatisfy`,
40
+ `UnknownSubstitutionSequence`, `InfoSubstitutionWithoutHeader`,
41
+ `TextAfterEofMarker`, `UntilBeforeFrom`, `CoordinateNotString`,
42
+ `ShellUseWhileRunDisabled`, `LiteralTypeMismatch`.
43
+
44
+ - [x] **UnknownSubstitutionSequence** — `dosubst.c:820` `default:` emits the
45
+ character and drops the `%`. Silent. Valid set: `A`–`Z` case-insensitive
46
+ (`UPPER(c)` at `dosubst.c:482`), `0`–`9`, `!?@#:_"`, plus `%<Header>`,
47
+ `%(text)`, `%{name}` and the `%*` modifier.
48
+ **Two corrections.** Every letter and every digit is a case, so there is
49
+ no `%z` typo to catch — the rule is really about punctuation and
50
+ unterminated argument forms. And a trailing `%` is *documented*: it
51
+ suppresses the appended newline (`remind.1`; `dosubst("hello")` is 6
52
+ characters, `dosubst("hello%")` is 5). Flagging it produced 209 false
53
+ positives on `tests/`.
54
+ - [x] **InfoSubstitutionWithoutHeader** — `%<Name>` with no matching `INFO` on
55
+ the command. `FindTrigInfo` returns NULL and nothing is shipped
56
+ (`dosubst.c:293`), so the body reads `Meeting at ` like a truncation bug.
57
+ - [x] **UntilBeforeFrom** — `UNTIL` earlier than `FROM`. **Two corrections.**
58
+ Remind is *not* silent: it warns at parse time, with a separate message
59
+ for `SCANFROM`, which the rule now covers too. And equal dates are a
60
+ legal one-day window that fires — `FROM 1992-01-06 UNTIL 1992-01-06`
61
+ triggers on the day — so the comparison is strict.
62
+ - [x] **IftrigWithSatisfy** — `IFTRIG` takes any trigger a `REM` does *except*
63
+ `SATISFY`. Easy to hit by copying a `REM`.
64
+ - [x] **TimeZoneWithoutAt** + **DurationWithoutAt** — built as one rule,
65
+ `ClauseRequiresAt`. Both are "this clause needs a time"; the message names
66
+ which clause and why. **Corpus correction:** `AT` is not the only source
67
+ of a time. `include/lunar-eclipses.rem` supplies it from a pasted
68
+ `[utctolocal(...)]` DATETIME, 142 times. A trigger containing any
69
+ bracketed expression is now left alone.
70
+ - [x] **LatitudeLongitudeNotString** — built as `CoordinateNotString`. Remind
71
+ has no float type, so `SET $Latitude 45.42` is not a near miss, it is a
72
+ different thing. Out-of-range values produce confidently wrong sunrise
73
+ times.
74
+ - [x] **TextAfterEofMarker** — `files.c:421`, exact match on a line of
75
+ `__EOF__`. Everything below is dead text that reads like configuration.
76
+ - [x] **CrossTypeEqualityAlwaysFalse** — built as `LiteralTypeMismatch`, which
77
+ also covers the ordering half of **OperandTypeMismatch**: `compare()` in
78
+ `expr.c` makes `==` a constant 0 and `!=` a constant 1 across types, and
79
+ raises `Type mismatch` for `<`, `>`, `<=`, `>=`.
80
+ - [x] **ShellUseDefinedWhileRunDisabled** — built as `ShellUseWhileRunDisabled`.
81
+ Confirmed in the source: `userfns.c:280` captures the flag at definition
82
+ and `expr.c:777` re-applies it at every call.
83
+
84
+ ## Tier 3 — exact, worth having, lower frequency (20) — DONE
85
+
86
+ Built as eleven rule classes, merging where the check is one check:
87
+ `ClauseValueRange`, `DateOutOfRange`, `ClauseNeedsFullDate`, `RepeatTrigger`,
88
+ `InfoClause`, `TagSyntax`, `BannerPlacement`, `UnknownSpecialType`,
89
+ `StringEscape`, `DebugCommand`, `ShellMaxlen`, plus the nesting limit folded
90
+ into `UnbalancedBlocks`.
91
+
92
+ **Four more corpus corrections**, all from `tests/`:
93
+
94
+ - `DURATION` has no hour ceiling. A duration is a *length*, not a time of day;
95
+ `tests/test3.rem` writes `DURATION 24:45` and `DURATION 48:45` and Remind
96
+ accepts both. Only the minutes are bounded. (12 false positives.)
97
+ - `OMIT ... THROUGH ...` takes partial dates on purpose -- it is the omit-range
98
+ syntax, not a reminder's expiry. `OMIT Jun THROUGH July 15` is in
99
+ `tests/test.rem`. (3 false positives.)
100
+ - The `SPECIAL` set includes `PostScript`, `PSFile` and `PS`, which rem2ps
101
+ compares `passthru` against and which look like reminder types rather than
102
+ SPECIAL ones. (2 false positives.)
103
+ - `AT 13:00AM` really is an error -- `Ill-formed time`, confirmed against the
104
+ binary -- so that one stayed.
105
+
106
+
107
+ `DateLiteral` (built for `UntilBeforeFrom`) already reads Remind's date forms
108
+ in any order, so the three date-range rules are mostly wiring. `Trigger` gives
109
+ the clause positions the time and priority rules need.
110
+
111
+
112
+
113
+ - [x] **TriggerComponentRange** / **DateConstantBeforeEpoch** /
114
+ **DateOutsideRepresentableRange** — merge into one. `BASE 1990` +
115
+ `YR_RANGE 4000` (`custom.h.in:80,86`) gives 1990-01-01 … 5990 exactly.
116
+ - [x] **TimeValueRange** — `AT 25:00`, `AT 9:70`.
117
+ - [x] **PriorityOutOfRange** — `ParsePriority` (`dorem.c:2143`) is `p<0 || p>9999`
118
+ → `E_2HIGH`. Note: `PRIORITY -1` returns `E_EXPECTING_NUMBER` instead, and
119
+ the message should say so.
120
+ - [x] **IntConstantOutOfRange** — Remind's INT is the platform's C int.
121
+ *Deferred: the bound is the build's `int`, which the linter does not know,
122
+ and a literal that large is vanishingly rare.*
123
+ - [x] **InvalidStringEscape** — closed escape set; `\x00` prohibited outright.
124
+ - [x] **TildeBackWithDayComponent** — `~~n` has an implied day component of 1;
125
+ writing both is a contradiction. *Deferred with BackSugarAvailable: both
126
+ need delta/back parsing that nothing else wants yet.*
127
+ - [x] **PartialDateAfterFromUntil** — both keywords require a full date.
128
+ - [x] **RepeatWithoutFullStartDate** — a repeat counts from a start date.
129
+ - [x] **WeekdayWithRepeat** — the weekday picks only the start date; the
130
+ recurrence ignores weekdays. `REM Fri 15 Sep 2025 *10` fires on days that
131
+ are mostly not Fridays.
132
+ - [x] **BannerPlacement** — only the last `BANNER` matters.
133
+ - [x] **MaxOverdueNotPositive**, **CompleteThroughNotFullDate**
134
+ - [x] **TagSyntax** — a comma inside a tag splits it into two nobody meant.
135
+ - [x] **DuplicateInfoHeader** — headers are not case-sensitive, so `Url:` and
136
+ `URL:` on one command collide.
137
+ - [x] **InfoStringMalformed**
138
+ - [x] **HebrewDateOutOfRange** — *Deferred.* Month lengths are **not** fixed:
139
+ `hbcal.c` recomputes Heshvan and Kislev per year from the year length
140
+ (353/354/355 days), so only "no month has more than 30 days" is decidable
141
+ cheaply, and that catches almost nothing.
142
+ - [x] **MoonPhaseArgumentRange** — closed four-element sets. *Deferred: worth
143
+ building, but `moonphase`/`moondate`/`moondatetime` each take the selector
144
+ in a different position and the payoff is one literal check.*
145
+ - [x] **UnknownDebugFlag**, **DebugFlagLeftEnabled**
146
+ - [x] **TranslateCommandForm**, **TranslationFormatSpecifiers** — *Deferred to
147
+ the localization group in Tier 5, where the rest of the TRANSLATE and
148
+ callback rules live.*
149
+ - [x] **ShellMaxlenArgument** — `shell(cmd, 0)` returns nothing and looks exactly
150
+ like a command that produced nothing.
151
+ - [x] **UnknownSpecialType** / **SpecialBodyShape** / **DefaultColorFormat** —
152
+ extend the existing `ColorComponentRange`.
153
+ - [x] **IfNestingTooDeep** — `IF_NEST 64` (`ifelse.c:19`). Free, given the stack
154
+ `UnbalancedBlocks` already keeps. Nobody will ever hit it.
155
+
156
+ ## Tier 4 — needed machinery that did not exist — DONE, or decided
157
+
158
+ - [x] **FunctionRedefinition** — real: `suppress_redefined_function_warning` in
159
+ `userfns.c:204`, and `FSET - name(...)` is the documented escape hatch
160
+ (`WHATSNEW:714`). Sits next to `FunctionArity`.
161
+ - [x] **PushVarsMissingName** — reuses `UnbalancedBlocks`' stack. Subtle and
162
+ worth it: an unlisted assignment leaks past the `POP`, which is the one
163
+ thing the block was there to stop.
164
+ - [x] **CallbackFunctionSignature** / **SubstitutionCallbackSignature** —
165
+ `FunctionArity` inverted: check *definitions* against a table of callback
166
+ names and required arities. `check_subst_args(func, 3)` at `dosubst.c:372`.
167
+ - [x] **WarnSequenceNotDecreasing** / **SchedSequenceNotIncreasing** — literal
168
+ `choose()` sequences only.
169
+ - [x] **EasterdateFromToday** — pattern match on `easterdate(today())` in a
170
+ trigger carrying an offset. Narrow, exact, high value.
171
+ - [x] **SatisfyConstraintNotHoisted** — the strongest single rule in the set and
172
+ the only one with measured numbers behind it: 13.2M evaluations → 482K,
173
+ 2.30s → 0.58s. Narrow literal case only (`SATISFY [$Td == 13]` → move the
174
+ 13 into the trigger).
175
+ - [x] **UnsatisfiableSatisfy** — needs a small range check over trigger
176
+ components. `$Td == 100` is decidable.
177
+ - [x] **TriggerReuseWithoutScanfrom** — `$T` dataflow between adjacent `REM`s.
178
+ - [x] **MaxSatIterAbsurd**, **SatisfyBoundedByYear**
179
+ - [x] **IncludeRelativePath** — resolves against the working directory, not the
180
+ including file. Works from one directory, fails from another.
181
+ - [x] **SysIncludeAbsolutePath**, **IncludeTargetMissing** (literal paths only)
182
+ - [x] **AddOmitWithoutScanfrom** / **ScanfromTooShort** — the 28-day figure is
183
+ sourced (ch. 4 line 174); the 7-day one for monthly looks extrapolated and
184
+ should be checked before it ships.
185
+ - [x] **RecursionDepthExceeded** — `MAX_RECURSION_LEVEL 1000` (`custom.h:147`).
186
+ Only decidable for literal arguments to simple recursive functions. Real
187
+ work for a rare bug; near the bottom.
188
+ - [x] **MoonriseDatePartUnchecked**, **HebrewMonthName**,
189
+ **HebrewDateNeedsLeftToRightMark**, **MsgsuffixLeadingBackspace**
190
+ - [x] **WorldWritableScript** — a file-mode check rather than a content one, but
191
+ it fits: Remind refuses a world-writable script outright.
192
+ - [x] **QueuedComputedTimeWithoutNoqueue**, **RunOnInIncludedFile**
193
+
194
+ ## Tier 5 — style, off by default — DONE, or decided
195
+
196
+ - [x] **AdvanceWarningWithoutRelativeSubstitution** + **CalendarTextNotLimited**
197
+ — the strongest *pair*: one asks for `%b`, the other asks you to fence it
198
+ in `%"…%"`. Will fire a lot. Opt-in.
199
+ - [x] **TimedReminderWithoutTimeSubstitution** — same family, one axis over.
200
+ Note Remind already warns on `%1`–`%9` without `AT` at warning level
201
+ 05.03.04 (`dosubst.c:474`), so scope this to the complement.
202
+ - [x] **BackSugarAvailable** — `REM 1 --1` and `REM ~~1` are the same reminder.
203
+ - [x] **OmitAwareDeltaWithoutOmits** — with an empty omit context `+n` is exactly
204
+ `++n`, so the single sign is a typo or a missing `OMIT`.
205
+ - [x] **WeekdayDayTriggerConfusion** — demoted to informational. `REM Friday 13`
206
+ is the classic misunderstanding but occasionally what someone wants.
207
+ - [x] **SatisfyEvaluatedInForeignZone** — informational; correct, documented and
208
+ reliably surprising.
209
+ - [x] **HardCodedColorsWithoutPsCalGuard**, **CalKeywordVersusEmptyCalendarText**
210
+ - [x] **TkTagNamespace**, **GeneratedFileEdited**, **ConvertedFileHandEdited**,
211
+ **RemindOptionsInterfereWithServerMode**
212
+ - [x] **DebugCommandsCommitted**, **SystemVariableVersusTranslate**
213
+ - [x] **LangidNotTranslated**, **SubstitutionCallbackUnknownSequence**
214
+ - [x] **TodoWithoutCompleteThrough**, **RecurrenceNotExportable**
215
+ - [x] **UnknownTimeZoneName** + **TimeZoneNameCaseOnlyMismatch** — the second
216
+ turns the first's shrug into an actionable message by naming the zone that
217
+ was probably meant.
218
+
219
+ ---
220
+
221
+ ## Blocked on a decision
222
+
223
+ **Three rules need the invocation, which is not in the file.**
224
+ `SortOptionInScriptComment` (`-g`), `InfoHeadersNeedDashPP` (`-pp` vs `-p`) and
225
+ `TodoOutsideAgendaMode` (agenda vs calendar) all depend on how the file is run.
226
+ They share one prerequisite:
227
+
228
+ ```remind
229
+ # remlint:invocation remind -pp -g
230
+ ```
231
+
232
+ Build that once and all three work. Skip it and all three are guesswork. Decide
233
+ deliberately rather than discovering it three times.
234
+
235
+ **Five need to see past one file.** `UndefinedVariable`,
236
+ `CallbackFunctionMissing`, `AstronomyWithoutLocation`, `RunOnInIncludedFile`,
237
+ `FeatureNewerThanTargetVersion`. `FunctionArity` already stays silent on unknown
238
+ functions because helpers arrive through `INCLUDE`; `UndefinedVariable` is the
239
+ same problem one axis over and worse, since variables outnumber calls. Following
240
+ `INCLUDE` is an architecture change, and `INCLUDECMD` *executes*, so it cannot be
241
+ followed safely at all. The cheap answer is the `AllowedNames` config already
242
+ built for `UnknownSystemVariable`: declare what arrives from outside, lint the
243
+ rest.
244
+
245
+ **FeatureNewerThanTargetVersion needs a column the generator cannot produce.**
246
+ It wants "introduced in which version" for every keyword, function and system
247
+ variable. `tasks/generate_tables.rb` reads one checkout, so it cannot know.
248
+ Either walk the git history of `token.c`/`funcs.c`/`var.c`, or carry a
249
+ hand-maintained since-map that will drift. A design decision, not a rule.
250
+
251
+ **SyntaxRuleNeedsRemind is a linter self-diagnostic, not a per-file offence** —
252
+ but it points at a real gap. The `Syntax` rule currently skips silently when
253
+ `remind` is not on PATH, which means CI can believe it is syntax-checking when
254
+ it is not. That belongs as one warning from the runner.
255
+
256
+ ## Cut
257
+
258
+ - **RecurringReminderIgnoresOmits** — its own description concedes the default
259
+ "is right for most reminders". A rule that is wrong most of the time teaches
260
+ people to ignore the linter.
261
+ - **ScriptFileExtension** — would fire on `examples/astro` and
262
+ `examples/ansitext`, the two files the extractor layer exists to handle.
263
+ - **SecretCalendarUrl** — secret detection is its own discipline with its own
264
+ false-positive economics. `trufflehog` already does it.
265
+ - **HebrewSunsetOffset** — "Recording the idea here so the next reader does not
266
+ re-derive it" is an honest note, and undetectable. Keep it as a comment in the
267
+ chapter, not a rule.
268
+
269
+ ## A note on sourcing
270
+
271
+ The book's licence permits verbatim personal copies and expressly reserves the
272
+ work from text-and-data-mining. Some of the rule descriptions quote it directly
273
+ ("Verbatim from the book: …"). Rule doc comments in this gem must not carry that
274
+ prose: cite chapter and section, paraphrase the mechanism, and verify the
275
+ constant against the C source — which is GPL-2.0 and quotable — so a GPL gem is
276
+ not redistributing a book that forbids redistribution.
277
+
278
+
279
+
280
+ ---
281
+
282
+ # Final disposition
283
+
284
+ **53 rules, from 100 ideas. Nothing is deferred.** Every idea is either built,
285
+ merged into a rule that covers it, or deleted with the reason recorded.
286
+
287
+ ## Deleted, and why
288
+
289
+ Each of these was investigated to the point where a decision could be made.
290
+ None is "not done yet".
291
+
292
+ **Disproven — the premise is false.** These were built, run against the corpus,
293
+ and removed when Remind disagreed:
294
+
295
+ - **CalKeywordVersusEmptyCalendarText** — a `CAL` whose whole body is fenced in
296
+ `%"…%"` shows in the calendar perfectly well. Checked by rendering one:
297
+ `REM 1 Jan CAL %"Fenced%"` and `REM 2 Jan CAL Plain` both appear.
298
+ - **MsgsuffixLeadingBackspace** — `examples/defs.rem` writes
299
+ `FSET msgsuffix(x) char(13,10)+"***"+char(13,10,13,10)`, which wants its own
300
+ lines. `char(8)` is one way to write a suffix, not the only correct one.
301
+ - **SubstitutionCallbackUnknownSequence** — the `x` variants are *not* limited
302
+ to the today/tomorrow family. `FSET subst_sx(a,d,t)` with `%*s` prints the
303
+ callback's result, so `include/lang/ca.rem`'s `subst_sx` is live code.
304
+
305
+ **Undecidable — the information does not exist.**
306
+
307
+ - **FeatureNewerThanTargetVersion** — needs "introduced in which version" per
308
+ keyword, function and system variable. The man page carries five such
309
+ annotations in total and the tree has no history to mine. There is no source
310
+ for the column.
311
+ - **IntConstantOutOfRange** — the bound is the build's `int`, which the linter
312
+ cannot know and which `configure` may change.
313
+ - **RecursionDepthExceeded** — bounding recursion depth from a literal argument
314
+ requires evaluating the function.
315
+ - **HebrewDateNeedsLeftToRightMark** — whether a bidi mark is needed depends on
316
+ what follows the month name at render time.
317
+ - **SatisfyEvaluatedInForeignZone** — correct, documented, and has no defect to
318
+ report. An informational note with no fix is documentation.
319
+ - **RemindOptionsInterfereWithServerMode** — which options interfere is
320
+ TkRemind's business and is not written down anywhere the linter can read.
321
+ - **RunOnInIncludedFile** — needs to know whether a file is top-level, which
322
+ depends on who included it.
323
+ - **RecurrenceNotExportable** — which recurrences `rem2ics` can express is
324
+ rem2ics's business, and it is not in this tree.
325
+
326
+ **Wrong by construction.**
327
+
328
+ - **RecurringReminderIgnoresOmits** — wrong most of the time by its own
329
+ description.
330
+ - **ScriptFileExtension** — would fire on the two files the extractor exists for.
331
+ - **SecretCalendarUrl** — secret detection is its own discipline with its own
332
+ false-positive economics; `trufflehog` does it.
333
+ - **HebrewSunsetOffset** — an honest note, and undetectable.
334
+ - **IncludeTargetMissing** — `INCLUDE` resolves against the *working directory*,
335
+ which the linter does not know at lint time, so a missing target cannot be
336
+ distinguished from a different working directory. `IncludePath` reports the
337
+ hazard that causes it instead.
338
+ - **TildeBackWithDayComponent**, **WeekdayDayTriggerConfusion**,
339
+ **SystemVariableVersusTranslate**, **HardCodedColorsWithoutPsCalGuard**,
340
+ **QueuedComputedTimeWithoutNoqueue**, **BackSugarAvailable**,
341
+ **WarnSequenceNotDecreasing**, **SchedSequenceNotIncreasing**,
342
+ **TriggerReuseWithoutScanfrom**, **MoonriseDatePartUnchecked**,
343
+ **ConvertedFileHandEdited** — each is a real observation, and each reduces to
344
+ a preference, a shape too narrow to be worth a rule, or something a built rule
345
+ already covers. `GeneratedFileEdited` covers the last with a configured glob;
346
+ `RepeatTrigger`'s weekday check covers the stronger half of the weekday pair.
347
+
348
+ **Relocated.**
349
+
350
+ - **SyntaxRuleNeedsRemind** — built, but as a *runner* diagnostic rather than a
351
+ per-file offence: `remlint` now prints
352
+ `remlint: the Syntax rule is enabled but ... is not on PATH, so no file was
353
+ syntax-checked` once per run. Silently not syntax-checking is worse than not
354
+ syntax-checking.
355
+ - **SortOptionInScriptComment**, **InfoHeadersNeedDashPP**,
356
+ **TodoOutsideAgendaMode** — all three needed the invocation, which is not in
357
+ the file. `Invocation` and the `# remlint:invocation` declaration were built,
358
+ and `InvocationMismatch` covers all three.
359
+
360
+ ## What the corpus was worth
361
+
362
+ Fifteen premises did not survive contact with the source, the man page, the
363
+ corpus or a running binary. Not one was caught by reasoning.
364
+
365
+ | Premise | What is actually true |
366
+ | --- | --- |
367
+ | `%z` is a typo | every letter and digit is a real sequence |
368
+ | a trailing `%` substitutes nothing | it suppresses the appended newline |
369
+ | `DURATION` is a time of day | it is a length; `DURATION 48:45` is fine |
370
+ | `AT` is the only source of a time | a pasted DATETIME supplies one |
371
+ | `UntilBeforeFrom` is silent | Remind warns, and `SCANFROM` has its own message |
372
+ | `UNTIL` on or before `FROM` | equal dates are a legal one-day window |
373
+ | `THROUGH` always needs a full date | not inside `OMIT` |
374
+ | the `SPECIAL` set is six names | `PostScript`, `PSFile` and `PS` are in it |
375
+ | any `subst_` name is a callback | only single-char overrides and `%{name}` |
376
+ | a narrow `SCANFROM` is a bug | a smaller guarantee; `defs.rem` uses `-7` |
377
+ | Hebrew months have fixed lengths | Heshvan and Kislev vary with the year |
378
+ | a fenced `CAL` shows nowhere | it shows in the calendar |
379
+ | `msgsuffix` must start with `char(8)` | `char(13,10)` is a valid choice |
380
+ | Hebrew months have one spelling each | three tables: canonical, Ivrit, alternates |
381
+ | a TODO with no `COMPLETE-THROUGH` screams | it goes **silent**, and only with `MAX-OVERDUE` |
382
+
383
+ Two of my own bugs came from the same place: `Trigger` reading the English word
384
+ "run" in an `ERRMSG` as a shell body, and `%"` counted as injectable data.
385
+
386
+ ## Corpus state
387
+
388
+ `examples/`, `include/` (584 files) and `contrib/` are **clean**. `tests/`
389
+ reports 84, all verified by hand, many on lines that suite labels "Should
390
+ fail", "Bad:" or "Diagnosed".
data/docs/remlint.md ADDED
@@ -0,0 +1,238 @@
1
+ # RemLint
2
+
3
+ A style and consistency linter for [Remind](https://dianne.skoll.ca/projects/remind/)
4
+ reminder files.
5
+
6
+ ```
7
+ $ remlint examples tests
8
+ tests/if1.rem:3:1: error: [UnbalancedBlocks] `IF` is never closed by `ENDIF`
9
+ tests/test.rem:1146:9: error: [FunctionArity] `version` takes 0 arguments, given 1
10
+ tests/test.rem:592:6: error: [UnknownSystemVariable] `$aaaa…` is not a Remind system variable
11
+ 3 offences (3 errors)
12
+ ```
13
+
14
+ ## Why it is shaped like this
15
+
16
+ **Remind is not Ruby, so RuboCop's machinery does not apply.** RuboCop's whole
17
+ pipeline assumes a `parser`/Prism AST, and its one escape hatch —
18
+ `RuboCop::Runner.ruby_extractors` — exists to pull *Ruby* back out of ERB and
19
+ Haml templates. Neither helps here.
20
+
21
+ The precedent that does apply is **puppet-lint**: a linter written in Ruby for a
22
+ language that is not Ruby. Including where puppet-lint draws its line — it
23
+ validates style, and leaves "is this even valid" to the real parser. RemLint
24
+ does the same. `Syntax` shells out to Remind itself, and is off by default,
25
+ because running the file is what checking it costs.
26
+
27
+ **Remind's grammar decides the architecture.** The manual describes a reminder
28
+ file as a list of commands, one per line, with backslash continuation; comments
29
+ open with `#` or `;`; keywords are case-insensitive and abbreviable; and the
30
+ `REM` that opens a trigger may be left off entirely. That is line-oriented, not
31
+ tree-oriented. So there is no grammar here — there is a pipeline:
32
+
33
+ ```
34
+ bytes → sources → logical lines → commands → tokens → rules → offences
35
+ ```
36
+
37
+ | Stage | File | What it settles |
38
+ | --- | --- | --- |
39
+ | sources | `extractors.rb` | which parts of a file are Remind, and at what line offset |
40
+ | logical lines | `logical_line.rb` | where backslash continuations join, and where they only look like they do |
41
+ | commands | `command.rb` | which keyword opens a line, and whether one does at all |
42
+ | tokens | `expr_lexer.rb` | where the strings, brackets, `$SysVars` and calls are |
43
+ | rules | `rules/*.rb` | one question each, over whichever view above is narrowest |
44
+
45
+ **The vocabulary is generated, not remembered.** Every keyword, its minimum
46
+ abbreviation length, every function's argument count, every system variable's
47
+ writability and range comes from Remind's own dispatch tables —
48
+ `src/token.c`, `src/funcs.c`, `src/var.c` — transcribed into `lib/remlint/tables.rb`
49
+ by `tasks/generate_tables.rb`. So `INC` resolves to `INCLUDE` and `OMI` resolves
50
+ to nothing for the same reason Remind says so, and `ampm` takes one to four
51
+ arguments because that is the row in Remind's table.
52
+
53
+ ```
54
+ $ rake tables[/path/to/remind]
55
+ lib/remlint/tables.rb: 91 keywords, 145 functions, 126 system variables
56
+ ```
57
+
58
+ **It reads Remind out of shell scripts.** `examples/ansitext` and
59
+ `examples/astro` are shell scripts that pipe Remind in through heredocs —
60
+ `astro` has four of them — and a linter that only globbed `*.rem` would miss
61
+ most of the Remind in that directory. Line numbers are reported against the
62
+ enclosing file, so an offence in `astro`'s third heredoc points at the real line
63
+ of `astro`.
64
+
65
+ ## Rules
66
+
67
+ 53 rules. `remlint --show-rules` lists them with their current state; each
68
+ carries its reasoning, and the file and line of the C that settles it, in a
69
+ comment at the top of `lib/remlint/rules/`.
70
+
71
+ **On by default** — each reports something Remind itself rejects, that breaks
72
+ its output, or that fails silently on a day months away:
73
+
74
+ `TrailingWhitespace` · `DanglingContinuation` · `UnbalancedBlocks` ·
75
+ `UnbalancedDelimiters` · `FunctionArity` · `UnknownSystemVariable` ·
76
+ `SystemVariableAssignment` · `ColorComponentRange` · `UnquotedShellSubstitution` ·
77
+ `ClauseRequiresAt` · `IftrigWithSatisfy` · `UnknownSubstitutionSequence` ·
78
+ `InfoSubstitutionWithoutHeader` · `TextAfterEofMarker` · `UntilBeforeFrom` ·
79
+ `CoordinateNotString` · `ShellUseWhileRunDisabled` · `LiteralTypeMismatch` ·
80
+ `ClauseValueRange` · `DateOutOfRange` · `ClauseNeedsFullDate` · `RepeatTrigger` ·
81
+ `InfoClause` · `TagSyntax` · `BannerPlacement` · `UnknownSpecialType` ·
82
+ `StringEscape` · `DebugCommand` · `ShellMaxlen` · `FunctionRedefinition` ·
83
+ `PushVarsMissingName` · `CallbackSignature` · `IncludePath` ·
84
+ `EasterdateFromToday` · `TimeZoneName` · `WorldWritableScript` ·
85
+ `TodoCompleteThrough` · `AddomitWithoutScanfrom` · `TranslateCommand` ·
86
+ `HebrewDate` · `MoonPhaseArgument` · `TkTagNamespace` · `InvocationMismatch` ·
87
+ `LocalizationPack` · `GeneratedFileEdited`
88
+
89
+ **Off by default** — house style, one performance rule, and the one that runs
90
+ the file:
91
+
92
+ `KeywordCase` · `LineLength` · `LicenseHeader` · `SatisfyConstraint` ·
93
+ `AdvanceWarningBody` · `CalendarTextLimited` · `OmitAwareDelta` · `Syntax`
94
+
95
+ `Syntax` is off deliberately: Remind has no parse-only mode, so it runs the
96
+ file. `-r` disables `RUN`; `INCLUDECMD` still executes.
97
+
98
+ ### Where the rules deliberately stay quiet
99
+
100
+ A linter earns its output by what it does *not* say. The recurring principle:
101
+ **where the linter cannot know, it says nothing.**
102
+
103
+ - A closing bracket with nothing open is text, not an error — `MSG See note ]`
104
+ prints a bracket.
105
+ - Parentheses count only inside `[...]` or an expression command; `MSG Call
106
+ (555) 1234` is text.
107
+ - Unknown functions are not reported: a file's helpers usually arrive through
108
+ `INCLUDE`, which one-file linting cannot see.
109
+ - A trigger carrying a bracketed expression suppresses the clause rules that
110
+ depend on knowing what it evaluates to.
111
+
112
+ ### Declaring how a file is run
113
+
114
+ Three checks need the command line rather than the file. Declare it once and
115
+ they work; leave it out and they stay silent.
116
+
117
+ ```remind
118
+ # remlint:invocation remind -pp -g /path/to/file
119
+ ```
120
+
121
+ `InvocationMismatch` then reports an unreadable `-g` sort spec, `INFO` under a
122
+ plain `-p` that will not carry it, and `TODO` under a calendar invocation where
123
+ its semantics do not exist.
124
+
125
+ `RULES.md` records the full backlog: every one of the 100 ideas built, merged or
126
+ deleted with its reason, and the thirteen book premises the source and corpus
127
+ corrected.
128
+
129
+ ## Configuration
130
+
131
+ `.remlint.yml`, found by walking up from the working directory, merged over the
132
+ shipped defaults **key by key** — so setting one option leaves the rest alone.
133
+
134
+ ```yaml
135
+ TrailingWhitespace:
136
+ Severity: error
137
+
138
+ KeywordCase:
139
+ Enabled: true
140
+ EnforcedStyle: consistent # upper, lower, or the file's own majority
141
+
142
+ UnknownSystemVariable:
143
+ AllowedNames: [Latitude, Longitude] # fed in with remind -i$Name=...
144
+
145
+ Exclude:
146
+ - "examples/tflag.rem"
147
+ ```
148
+
149
+ `remlint --show-config` prints what is actually in effect.
150
+
151
+ ### Silencing one line
152
+
153
+ ```remind
154
+ MSG trailing spaces here # remlint:disable TrailingWhitespace
155
+ ```
156
+
157
+ ```remind
158
+ # remlint:disable TrailingWhitespace, UnbalancedBlocks
159
+ IF something
160
+ ```
161
+
162
+ A directive covers the line it is on and the line below it. `all` covers every
163
+ rule. Both comment characters work.
164
+
165
+ ## Command line
166
+
167
+ ```
168
+ remlint [options] [path...]
169
+
170
+ -c, --config PATH Configuration file (default: nearest .remlint.yml)
171
+ -o, --only RULES Run only these rules
172
+ -f, --format TEMPLATE Output template
173
+ --fail-level LEVEL Exit non-zero at this severity or worse (default: warning)
174
+ --show-rules List the rules and whether they are on
175
+ --show-config Print the configuration in effect
176
+ ```
177
+
178
+ With no paths, the working directory. Exit status is 0 when nothing at or above
179
+ `--fail-level` was found, 1 when something was, and 2 for a usage error.
180
+
181
+ The default output line — `path:line:column: severity: [Rule] message` — is what
182
+ vim's default `errorformat` reads. `--format` takes the same `%{...}`
183
+ placeholders puppet-lint uses:
184
+
185
+ ```
186
+ $ remlint --format '::error file=%{path},line=%{line}::%{message}' .
187
+ ```
188
+
189
+ ## Development
190
+
191
+ Tests live in each file's `__END__` section and run under
192
+ [scampi](https://github.com/general-intelligence-systems/scampi): the specs
193
+ never load in production, because Ruby stops parsing at `__END__`.
194
+
195
+ ```
196
+ $ bundle install
197
+ $ rake # tests, then the house cops
198
+ $ rake test
199
+ $ rake rubocop
200
+ $ rake smoke[/path/to/remind] # lint Remind's own corpus
201
+ ```
202
+
203
+ The linter's own Ruby is checked by the custom cops in `cops/`, configured in
204
+ `.rubocop.yml`.
205
+
206
+ ### The corpus is the real test
207
+
208
+ RuboCop's advice for a new cop applies verbatim: run it over a significant
209
+ codebase. Remind ships one, and it earned its keep — **fifteen** premises are
210
+ narrower than they started, or gone entirely, because the source, the man page
211
+ or a running binary said so; two of the linter's own bugs surfaced the same way;
212
+ and three rules were built, run, disproven and deleted.
213
+
214
+ On the current release `remlint` is clean on `examples/`, `include/` (584 files)
215
+ and `contrib/`, and reports 84 offences in `tests/` — all verified by hand, many
216
+ on lines that suite labels "Should fail", "Bad:" or "Diagnosed".
217
+
218
+ A few of the corrections, as a flavour of what a corpus is for:
219
+
220
+ - **A trailing `%` is documented**, not a stray. It suppresses the newline
221
+ Remind would otherwise append: `dosubst("hello")` is six characters and
222
+ `dosubst("hello%")` is five. Flagging it produced 209 false positives.
223
+ - **`DURATION` is a length, not a time of day.** `tests/test3.rem` writes
224
+ `DURATION 48:45` for an event running over two days, and Remind accepts it.
225
+ - **`AT` is not the only source of a time.** `include/lunar-eclipses.rem`
226
+ supplies one from a pasted `[utctolocal(...)]` DATETIME, 142 times.
227
+ - **Only some `subst_` names are callbacks.** Remind builds `subst_<c>` for a
228
+ *single* character, so `subst_a` is one and `subst_a_alt` is an ordinary
229
+ helper — and `include/lang/` is full of helpers. 45 false positives.
230
+ - **`errmsg Please run [filename()] ...`** in `tests/tstlang.rem` made the
231
+ trigger parser read the English "run" as the start of a shell body, and then
232
+ report the rest of the sentence as command injection.
233
+
234
+ `RULES.md` has the full list.
235
+
236
+ ## Licence
237
+
238
+ GPL-2.0-only, as Remind is.
data/exe/remlint ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+
6
+ require "remlint"
7
+
8
+ exit(RemLint::CLI.new.call(ARGV))