PLANC Language Rules - a checkable reference for a linter author¶
Full path: E:\Dev\Ronny\NDInsight\Developer\Languages\Application\PLANC-LANGUAGE-RULES.md
What this is. A rule-by-rule reference for anyone writing or extending a PLANC linter. Each rule says what is WRONG, what is RIGHT, how a machine can spot it, and where in the manuals it comes from. It is not a tutorial and it is not a grammar; the grammar is Appendix F of ND-60.117.5.
What PLANC is, and where it came from¶
Context rather than rules. Everything in this section is sourced from the Wikipedia article "PLANC" and is an ENCYCLOPEDIA CLAIM, not something measured on the machine or taken from a manual. It is kept separate from the numbered rules for that reason. Where it touches anything this project has measured, the measurement wins and the disagreement is written down below rather than smoothed over.
- PLANC stands for Programming LAnguage for Nd Computers, and is pronounced "plank".
- Norsk Data built it as a cross-platform systems-programming language, aimed at operating system components and compilers. It was proprietary.
- It is procedural, imperative and structured, in the Pascal family, with static strong typing
and lexical scoping - though it has no generic
BEGIN/END: blocks areROUTINE/ENDROUTINE,DO/ENDDOand so on. - The store operator
=:is inherited, not an oddity. Left-to-right value flow -5 =: a =: b- is shared with Plankalkuel, ALGOL 60, Mary and C. Mary is the RUNIT systems language, so this is a direct Norwegian-computing lineage. Worth knowing because R19 and R42 stop looking arbitrary once you see where the operator comes from.
Two claims in that article that this project's own measurements contradict¶
1. "Array pointers were three-word structures containing base address, lower bound and higher bound, enabling reliable runtime boundary checking."
The three-field SHAPE is corroborated independently - the ghidra-planc skill records an 8-byte
descriptor {long virtualOrigo, word lowerLimit, word upperLimit} read out of real MC68000
firmware. The ND-100 word layout has NOT been measured here, so do not assume it is the same.
The "reliable runtime boundary checking" half is what conflicts. R116 and R117 are MEASURED on
D100: PLANC checks no array bounds, and that reaches the tests too - a test that wrote past the
end of an array still reported success. The likeliest reconciliation is that the descriptor makes
checking POSSIBLE and $OPTION ARRAY-INDEX-CHECK turns it on, whose initial value is documented
as OFF; MAXINDEX/MININDEX read the same descriptor (R53, R115). That reading leaves both
statements true. Do not soften R116 or R117 on the strength of the article - the default is
off, and the default is what every source in this project compiles under.
2. FOR ... DO ... ENDDO "with optional step specification".
Two problems. R58 gives the FOR list forms from RM5 6.5 - explicit values, implied ranges a:b,
one-dimensional arrays, pointer implied ranges, and REVERSE - and there is no step in any of
them, nor in planc-lint.py. The article also writes the terminator as ENDDO; ours is
ENDFOR, which is measured. At least the terminator is wrong, so treat the step as an unverified
claim. Appendix F's for statement production in ND-60.117.5 would settle it without touching the
machine.
A third, smaller one: the article names MC88000 and Intel x86 compilers, and NORD-10. Our
measured compiler-command table has $TARGET-MACHINE evaluating to 100, 500 or 68000 only.
Any 88000 or x86 compiler is a different product generation or an article error; it is not a
change to that table.
Sources, and which one wins¶
| Tag | Manual | Covers |
|---|---|---|
| RM5 | ND-60.117.5 EN PLANC Reference Manual, 5th ed. March 1986 | Compiler version G. The authority for the ND-100 compiler this project uses. |
| UG6 | ND-860117.6 EN PLANC User Guide and Reference Manual, June 1989 | Compiler versions H/I/J. Newer, has features version G does not. |
| ADV | ND-20034.1 EN ND-Specific Programming and Advanced PLANC | Lists exactly which features are NEW, i.e. absent from version G. |
| CODE | SINTRAN\XMSG\SINTRAN-CHAT\CHAT.PLNC, CHATSV.PLNC, SINTRAN\XMSG\tools\planc-lint.py |
Behaviour measured on a real ND-100 running PLANC version F. |
The version question decides several rules. The ND-100 in this project runs PLANC F (CODE, build headers). RM5 documents G. UG6 documents H/I/J and describes a LARGER language. Any rule below marked [NEWER ONLY] describes something UG6 allows that the ND-100 compiler here does not have. A linter aimed at this project should treat NEWER ONLY constructs as errors by default, with a switch to allow them.
OCR warning for anyone re-checking a citation. Both manual transcriptions garble the store
operator: =: is frequently rendered ::, :=, =:: or :=: inside example code. Where the
running text and the index disagree with an example, the running text and the index win. The
authoritative statements are RM5 index ("=: operator ... 91", ":=: operator value of ... 93"),
RM5 Appendix F (<assignment op> ::= =: | :=:) and UG6 pages 141/144.
Notation. Regexes are Python, case-insensitive unless stated. "code line" means: a source line
with any trailing % comment removed (respecting quotes) and blank/comment-only lines skipped.
Section 1 - Lexical rules¶
R1 - Identifier first character¶
Wrong: 5abc, _under, $name
Right: abc5, under_score
An identifier is a sequence of letters, digits and underscores whose first character must be a
letter.
Detect: flag a declared name matching ^[^A-Za-z]. Practically: capture the name after : in
a declaration and test ^[A-Za-z].
False positives: routine identifiers built from special characters (R9) are legal and start
with a non-letter. Exclude any name that consists ONLY of characters from the special set. Also
exclude @ (the in-value token) and compiler commands starting $.
Source: RM5 2.11 (p.30); Appendix F <identifier>; UG6 p.232 (_).
R2 - Identifier body characters¶
Wrong: in-valid (hyphen), a.b
Right: in_valid
Only letters, digits and _.
Detect: name matching [^A-Za-z0-9_].
False positives: same as R1 - special-character routine names. Also SINTRAN file names inside
string literals contain - and : legally; only test identifiers, never string contents.
Source: RM5 2.11; RM5 error message ILLEGAL CHARACTER.
R3 - Trailing and doubled underscore¶
Wrong (RM5/version G): abc_, a__b
Right: a_b_c
RM5 2.11 states plainly: "An underscore must not be the last character of an identifier and only
single underscore characters may be used (i.e. two consecutive underscore characters are
invalid)."
CONTRADICTION, and you must decide which to enforce. UG6 p.232 says Under_score and
Under_ are both valid names, and ADV p.62 lists "Multiple Underscores in Identifiers" as a NEW
feature. So the restriction is real for version G and was lifted later.
Detect: _$ on a name, or __ inside a name.
False positives: none in version G. On a newer compiler this rule is pure noise - make it
optional and default it ON only when targeting the ND-100.
Source: RM5 2.11 (p.30) vs UG6 p.232 and ADV 4.1 (p.62). Marked as a genuine version split,
not an assumption.
R4 - Only the first ten characters are significant¶
Wrong:
BYTES : memberName(1:16)
INTEGER : memberNameLen
MEMBERNAME. The second declaration is a redeclaration of the first.
Right: memberName and memberLen - distinct within ten characters.
An identifier may be any length, but only the first ten characters identify it.
What it actually looks like when it goes wrong (CODE, measured): the compiler answers
IDENTIFIER ALREADY SPECIFIED/DECLARED for the second declaration, and then every later
memberName(slot, i) draws MORE SUBSCRIPTS THAN IN THE ARRAY DECLARATION, because the name now
means the one-dimensional array. Four errors from one cause, and none of them mentions length.
The compiler listing is the tell - it prints the TRUNCATED names.
Detect: collect every declared name (variables, routines, parameters, TYPE names, CONSTANT
names). Group by name[:10].upper(). Report any group with more than one distinct name.
False positives: a name declared twice in genuinely separate scopes (two routines each with a
local counterValue1 / counterValue2) is legal in principle - but note R113: PLANC forbids an
inner routine redeclaring a name whose scope already includes it, so most collisions are real.
Being scope-blind here is the correct trade.
Source: RM5 2.11 (p.30); RM5 Appendix G restriction 27A (p.347); CODE planc-lint.py.
R5 - Significance limits other than ten¶
These are separate limits and a linter can check each:
| Limit | Applies to | Source |
|---|---|---|
| 10 chars | identifier uniqueness inside the compiler | RM5 2.11, Appendix G 27A |
| 8 chars | $CROSS-REFERENCE output when the compiler runs on an ND-100 |
RM5 Appendix G 27B; UG6 p.163 |
| 7 chars | EXPORTed/IMPORTed identifiers in BRF relocatable code (ND-100) | RM5 Appendix G 27C |
| no practical limit | EXPORT/IMPORT in NRF (ND-500/MC68000) | RM5 Appendix G 27C |
| 29 chars | ALIAS names passed to the loaders | RM5 Appendix G 27D |
| 16 chars | $LONG-NAMES ON uniqueness [NEWER ONLY], versions I/J, not available on ND-100 |
UG6 p.190; ADV 4.1 |
| 16 bytes | module.routine composite for $SELECT |
UG6 p.220; ADV 4.1 |
Detect (the important one): for a file that EXPORTs or IMPORTs and targets the ND-100/BRF,
group exported/imported names by name[:7].upper() and report collisions.
False positives: only if the target is NRF. Gate on target machine.
R6 - Case is not significant, except inside literals¶
ident1 and IDENT1 are the same data-element. The compiler folds everything to uppercase
except single-character literals, string literals, and format descriptors in INPUT/OUTPUT (i.e.
anything between apostrophes).
Detect: two declarations differing only in case are a redeclaration - fold to uppercase before
every name comparison in every other rule. Conversely: never uppercase the inside of '...' when
analysing.
False positives: none.
Source: RM5 2.2 (p.66/p.19), RM5 2.4, RM5 2.11.
R7 - Comment syntax¶
Right: % everything after this on the line is comment
% may appear in any column. A comment runs to end of line.
%% is not a comment start. RM5 2.6: %% this is not a comment line - the first %% inside a
string is an escaped percent (R11), and outside a string a doubled percent does not begin a
comment. % %% but this is does.
[NEWER ONLY] (% ... %) bracketed comments, which may be embedded anywhere and may nest:
(% Comment (% inside %) here %).
Detect: a comment stripper must track quote state. Flag (% if targeting version F/G.
False positives: (% can occur legitimately inside a string literal. Strip strings first.
Source: RM5 2.6 (p.70), 2.10 (p.76); UG6 p.132; ADV 4.1 (p.62).
R8 - Line continuation with &¶
Wrong:
INTEGER : int1,int2,int3,
int4,int5
INTEGER : int1,int2,int3, & % this line will be continued
int4,int5
& after the statement text joins the next line to this one. The & may be followed by a
comment on the same line.
[NEWER ONLY] implicit continuation: UG6 p.143-144 says end-of-line is NOT end of statement if
it follows a comma in a list of items, or a comma or semicolon in a routine parameter list. Also
'a ' // & splitting of a long BYTES literal.
Detect: for version F/G, flag a code line ending in , that is not followed by &. Report as
a warning, not an error.
False positives: high on a newer compiler. Also, a trailing comma inside a string literal is
harmless. Strip strings and comments first, and check the LAST non-space character of the
remaining text.
Source: RM5 2.5 (p.69); UG6 p.143-144.
R9 - Statement separator ;¶
More than one statement per line requires ; between them. A trailing ; on a single statement is
allowed but not required.
Restriction: a statement containing a MACRO call, an INLINE routine call, or a $INCLUDE
command may be terminated by a semicolon, but no other statement may follow the semicolon.
Detect: for a line containing a known INLINE routine call or $INCLUDE, flag any non-blank
text after the first ;.
False positives: requires knowing which routines are INLINE - collect names declared
ROUTINE INLINE .... Semicolons inside string literals must be ignored.
Source: RM5 2.4 (p.69); RM5 Appendix G restriction 1 (p.344); UG6 p.154.
R10 - Special-character routine names¶
A routine identifier may alternatively be made only of these characters:
! " $ * + - . / : < = > ? \ [ ]
Rules: a $ cannot begin such a name; a . can only begin such a name; and a space must
precede the routine identifier if it begins with one of these characters.
Detect: for a declared routine name that is not a normal identifier, check the character set,
the $-first and .-first rules, and that the token before it (usually :) is followed by a
space.
False positives: low. Note R4 still applies - such names are also unique in ten characters.
Source: RM5 7.1 (p.176).
R11 - Escaping inside a string literal¶
Inside '...', the three characters %, & and ' must each be doubled.
| Written | Value |
|---|---|
'his && hers' |
his & hers |
'two %%% characters' |
two %% characters |
'Tom''s 5 %% share' |
Tom's 5 % share |
'''' |
' (one apostrophe) |
Why it bites: UG6 p.132 - a single % inside a string makes the compiler assume the string
continues on the next line, and it will read the rest of the program as a string looking for the
closing '. "A quite destructive compile-time error."
Detect: inside a scanned string literal, an odd-count run of % or & is an error.
Apostrophes are already handled by the doubling convention of the scanner itself.
False positives: none if the scanner is correct.
Source: RM5 2.10 (p.76); UG6 p.132, p.145-146.
R12 - Single-character literal #¶
Right: #a, #Z, #(
# followed by exactly one ASCII character yields that character's value, in a BYTE.
To write %, & or ' as a single-character literal, use one occurrence after the # -
the doubling rule of R11 does not apply here.
'a' is a BYTES and is NOT equivalent to #a, which is a BYTE. They have different
internal representations. This is the single most productive source of ILLEGAL DATA TYPE in real
code - see R76.
[NEWER ONLY] # also forms Ada radix notation: 16#7F#, 2#1101_1001#, radix 2 to 32.
Detect: flag # followed by two or more characters before a delimiter, unless it matches the
Ada form \b\d{1,2}#[0-9A-Fa-f_]+# and the newer dialect is enabled.
False positives: # inside strings. RM5's OCR renders this character as a pound-sign glyph in
several places - do not take that literally; the real character is #.
Source: RM5 2.9 (p.76), Appendix F <character literal>; UG6 p.133, p.146, p.158-159.
R13 - String subscripting is 0-based when the literal sets the bounds¶
Wrong: assuming string(1) is the first character of BYTES : string := 'i am the greatest'.
Right: string(0) is the first character; the last is string(len-1).
RM5 4.1.7.1 (p.109), verbatim: "the first character can be referenced by string[0], the second by
string[1] and so on." UG6 p.166: "If the range is specified by the initialization literal, the
bytes in the string are addressed with indexes ranging from zero up to the number of bytes in the
literal minus one."
But when you write the range yourself - BYTES : magic(1:100) - the bounds are exactly what you
wrote. Both forms exist in the same file in real code, which is why this must be tracked per
variable, not assumed globally.
Detect: build a table name -> (lo, hi). For BYTES : n := 'literal' set (0, len-1). For
BYTES : n(a:b) set (a, b). Flag a constant subscript outside [lo, hi].
False positives: none for constant subscripts. Do not attempt this for computed subscripts.
Source: RM5 4.1.7.1; UG6 p.165-166; CODE (a live defect: a ten-byte literal indexed to 16).
R14 - Octal literals¶
Right: 0B, 777B, -765B
Digits 0-7 only, then the letter B. Optional leading minus.
Detect: \b-?[0-9]+B\b where the digit run contains an 8 or 9 is an error.
False positives: an identifier ending in B preceded by digits cannot occur (R1), so this is
safe. Strings must be excluded.
Source: RM5 2.7.1 (p.71).
R15 - Real literals¶
| Wrong | Why |
|---|---|
12 |
valid integer, but no decimal point - it is not a REAL |
.0 |
no digit before the decimal point |
+1.2 |
must not be preceded by + |
1.5E2.5 |
exponent must be a whole number |
1.6E+2 |
exponent must not carry + |
The whole-number part must be present. The exponent must not be preceded by a space.
Detect: (?<![\w.])\.\d for the missing-leading-digit case; \+\d+\. for the leading plus;
[0-9]E[+] and E-?\d+\.\d for bad exponents.
False positives: . is also the record-access operator, so rec.5 cannot occur but
ADDR(x).0 would look similar - in practice a . followed by a digit is always a malformed real.
Strings and comments must be excluded.
Source: RM5 2.7.2 (p.72).
R16 - $ in column-anything means compiler command¶
A line whose first non-blank character is $ is a compiler command. Blanks may precede the $.
Such commands can only be written between statements - never in the middle of a statement, and
never between the continuation lines of a statement.
Detect: flag a $-command line whose previous code line ends with &.
False positives: $ inside an OUTPUT string literal is the newline character (R83) and is not
a command; only the FIRST non-blank character of a line matters. $* is inline assembly, which is
a compiler command too.
Source: RM5 Appendix A 0.2 (p.248).
Section 2 - Declarations¶
R17 - The declaration shape¶
data-type : ident [:= lit-exp] [, ident [:= lit-exp]] ...
VAR, no DIM, no
type-after-name.
Detect: a line that starts with a known type keyword but has no : before the first name is
malformed.
False positives: INTEGER also appears inside IMPORT (...), TYPE x = ..., ROUTINE ...
headers and CONSTANT expressions. Anchor to line start and exclude those leading keywords.
Source: RM5 3.1 (p.83).
R18 - Every type keyword the linter must know¶
Simple types: INTEGER, REAL, BOOLEAN, LABEL, VOID, ENUMERATION, POINTER
Composite type constructors: ARRAY, RECORD, SET, ROUTINE
Predefined: BYTE (= INTEGER RANGE (0:255)), BYTES (= BYTE ARRAY PACKED),
BITS (= BOOLEAN ARRAY PACKED)
Sized integer subtypes: INTEGER1 (8 bit, -128:127), INTEGER2 (16 bit, -32768:32767),
INTEGER4 (32 bit, -2147483648:2147483647)
Real subtype: REAL8 (64 bit, 10-76 to 1076, 15 significant digits)
Modifiers: RANGE (INTEGER only), PRECISION (REAL only), READ, WRITE, PACKED
[NEWER ONLY] BOOLEAN1, BOOLEAN2, UNSIGNED, PUBLIC
Detect: anything else appearing where a type is expected is either a user TYPE name (collect
those from TYPE x = ... and $INCLUDEd files) or an error.
False positives: heavy, if include files are not read. A linter that cannot read the includes
must NOT report unknown type names as errors.
Source: RM5 2.3 keyword list (p.67), 3.2, 3.3, 3.12, 4.1.7; UG6 p.152-153, p.186-187, p.227.
R19 - := initialises, = equivalences, =: stores¶
Three different things share a colon and an equals sign, and mixing them is the commonest error in this language.
| Token | Meaning | Where legal |
|---|---|---|
:= |
give a declared variable an initial value | declarations only |
= |
(a) CONSTANT value (b) address equivalence in a declaration (c) the equality operator | declarations and expressions |
=: |
store - the run-time assignment | executable statements |
:=: |
swap/change | executable statements |
Wrong: count := count + 1
Right: count + 1 =: count
What the compiler says (CODE, measured): ILLEGAL SYNTAX ":=", preceded by a WARNING that the
expression does not store a value - which reads like a separate complaint about the line.
Detect: on a code line that is NOT a declaration, ^\s*[A-Za-z]\w*\s*(\([^)]*\))?\s*:=.
False positives: a declaration line - exclude any line whose first token is a type keyword or a
known user TYPE name. :=: must not be mistaken for := - test for :=: first.
Source: RM5 5.1 (p.138), 2.3 (p.68), Appendix F <assignment op>; UG6 p.141, p.144;
CODE planc-lint.py.
R20 - Which declarations may carry an initialiser¶
RM5 3.1 (p.83): "Initial value is valid only for INTEGER, REAL, BOOLEAN types."
In practice arrays, records, sets, enumerations, pointers and BYTES all take initialisers too (RM5 4.1.1, 4.2.1, 4.3.1, 3.6, 3.7), each with its own form. The restriction that actually matters to a linter is the scope one, below.
Rule that bites: "An initial value should normally be used in the outer level of a module. If an identifier is to have an initial value inside a routine, then its access must be declared as READ."
Wrong (inside a routine):
INTEGER : counter := 0
INTEGER READ : counter := 0 (and then it can never change),
or declare it without an initialiser and store into it as the first statement.
Detect: a declaration with := that lies between a ROUTINE/PROGRAM header and its
ENDROUTINE, and does not contain READ.
False positives: none known. The mechanism is explained at RM5 8.6: a local without READ
access is allocated dynamically on the stack, so it has no place for a compile-time initial value.
Source: RM5 3.1 (p.83), 8.6 (p.221-222).
MEASURED 2026-08-24 on D100 - and it is a HARD ERROR, not the "normally" the manual's wording suggests. This entry used to say "report as a warning". The compiler disagrees:
166 (141)/MAINUI *** ERROR - INITIAL VALUE ILLEGAL HERE "SMALLOPEN"
167 (142)/MAINUI *** ERROR - INITIAL VALUE ILLEGAL HERE "BIGOPEN"
228 LINES COMPILED. 2 DIAGNOSTICS.
from BOOLEAN : smallOpen := FALSE inside a PROGRAM. Report it as an error.
AND IT IS WORSE THAN A FAILED COMPILE. The link then SUCCEEDED over the failed compile and the
program RAN, with both flags never set - so two menu keys silently did nothing while a third
worked, which reads as a logic bug and sent the diagnosis to the NOT operator, which was
innocent. The compile errors were on screen throughout and were read as clean, because the
0 DIAGNOSTICS that stays visible belongs to the SECOND pass and sits directly under a COMPILE
that had two.
Now enforced by SINTRAN/XMSG/tools/planc-lint.py, READ exempted, with the message saying
why it is expensive: the build still links and runs with the name unset.
R21 - CONSTANT uses =, never :=¶
Wrong: CONSTANT rows := 10
Right: CONSTANT rows = 10
CONSTANT ident [= lit-exp] [, ident = lit-exp] ...
Detect: ^\s*CONSTANT\b.*:=.
False positives: none.
Source: RM5 3.5 (p.86). Note RM5's own example on p.169 prints CONSTANT rows:=10 - that is
an OCR error, contradicted by the syntax box and every other example.
R22 - CONSTANT default-value rule¶
If = and the expression are omitted, the identifier is INTEGER and takes the next integer value
higher than the previous integer value in this CONSTANT statement; if there is no previous
integer value, it is 0.
CONSTANT zero, rl2=1.1, one, bl2=FALSE, two % zero=0, one=1, two=2
CONSTANT four=4, five, nine=four+five % five=5, nine=9
Detect: this is a semantic aid, not an error check - a linter that computes constant values should implement it, because it is easy to assume "just increments from the last item" and get it wrong when a REAL or BOOLEAN sits in between. Source: RM5 3.5 rules 1 and 2 (p.87); UG6 p.172-173.
R23 - ARRAY needs one ARRAY keyword per dimension¶
Wrong: REAL ARRAY : rl3(1:2,1:3,1:4) - three index sets, one ARRAY keyword.
Right: REAL ARRAY ARRAY ARRAY : rl3(1:2,1:3,1:4)
The compiler answers INCONSISTENT DIMENSIONS.
Detect: count ARRAY keywords in the type expression; count comma-separated index sets in the
declarator's parentheses; they must be equal. Add one implicit ARRAY for BYTES and BITS
(R25).
False positives: commas inside a nested expression in a bound - e.g. (1:MAXINDEX(a,1)) -
must not be counted. Count commas at parenthesis depth 1 only.
Source: RM5 4.1.1 (p.101), 4.1.2 (p.104); RM5 error INCONSISTENT DIMENSIONS.
R24 - ARRAY bounds are mandatory unless an initialiser supplies them¶
Wrong: INTEGER ARRAY : ar1 - error ARRAY BOUNDS MISSING.
Right: INTEGER ARRAY : ar1(1:5) or INTEGER ARRAY : ar3 := (-2,6,21,-108)
With an initialiser and no explicit bounds, the compiler supplies 0 : n-1.
Detect: an ARRAY declarator with neither ( bounds nor :=.
False positives: a declarator continued onto the next line with &. Join continuations first.
Source: RM5 4.1.1 (p.102); RM5 error ARRAY BOUNDS MISSING.
R25 - BYTES and BITS already contain one ARRAY keyword¶
BYTES is BYTE ARRAY PACKED; BITS is BOOLEAN ARRAY PACKED. So:
BYTES ARRAY : safe_els(0:9,0:9) % two dimensions
BYTE ARRAY ARRAY PACKED : safe_els(0:9,0:9) % exactly equivalent
Consequence a linter should flag: you cannot put a READ/WRITE modifier on the ELEMENTS of a
BYTES, because the modifier must precede all ARRAY keywords and BYTES hides the first one. If
element-level access modification is wanted, the declaration must be written out as
BYTE READ ARRAY ARRAY PACKED.
Detect: BYTES\s+(READ|WRITE) or BITS\s+(READ|WRITE) before an ARRAY keyword - report that
this modifies the whole array, not its elements, which is probably not what was meant.
False positives: it is legal, just usually not what the author intended. Warning, not error.
Source: RM5 4.1.7.1 (p.110), 4.1.7.2 (p.111).
R26 - READ/WRITE may not sit between ARRAY keywords¶
Wrong: INTEGER ARRAY READ ARRAY : a(1:2,1:3)
Right: INTEGER READ ARRAY ARRAY : a(...) (elements read-only) or
INTEGER ARRAY ARRAY READ : a(...) (whole array read-only)
RM5 4.1.4: "the access mode keywords, READ/WRITE, may not be placed between the ARRAY keywords. READ/WRITE must precede or follow all the ARRAY keywords."
Detect: in a type expression, ARRAY\s+(READ|WRITE)\s+ARRAY.
False positives: none.
Source: RM5 4.1.4 (p.107).
R27 - The meaning of READ/WRITE before vs after ARRAY¶
| Written | Effect |
|---|---|
INTEGER READ ARRAY : a |
cannot store into an individual element; can store the whole array |
INTEGER ARRAY READ : a |
cannot store the whole array; can store individual elements |
INTEGER READ ARRAY READ : a |
cannot store either way |
This is genuinely counter-intuitive and worth a linter note whenever a READ or WRITE appears in an array type, quoting which of the two it selected. Source: RM5 4.1.4 (p.107); UG6 p.223-224 (READ), p.242 (WRITE).
R28 - A RECORD type must be introduced by TYPE¶
Wrong: RECORD : myrec used as a bare declaration.
Right:
TYPE partrec = RECORD
INTEGER : partnumber
BYTES : partname(1:20)
ENDRECORD
partrec : mypart, yourpart
Detect: a line matching ^\s*RECORD\b that is not preceded on the same logical statement by
TYPE\s+\w+\s*=.
False positives: UG6 p.239 shows an anonymous RECORD ... ENDRECORD : DoublInt form
[NEWER ONLY]. Gate on dialect.
Source: RM5 4.2, 4.2.1 (p.112).
R29 - Record initialisation is all-or-nothing and order-exact¶
If ANY component of a record data-element is initialised, all components must be. The values must match the declared components in type and in order; a nested record component needs its own parenthesised group.
Wrong: partrec : psupply := (123) for a three-component record.
Right: partrec : psupply := (123,'power supply',100.2)
Detect: count top-level commas in the initialiser list and compare against the component count of the named TYPE. Requires resolving the TYPE, so only possible when the TYPE is in the same file or a readable include. False positives: variant records inherit the base record's components first, so the expected count is base-count + variant-count. Get this wrong and every variant initialiser is reported. Source: RM5 4.2.1 (p.114), 4.2.2 (p.116).
R30 - MOD in a record is alignment, not modulo¶
TYPE r = RECORD PACKED
BITS : b(11:37)
INTEGER : i MOD 4
ENDRECORD
MOD literal-expr forces the component to start at a
displacement from the record's start that is a multiple of that value.
Detect: nothing to flag; a linter must simply not mistake this MOD for the arithmetic
operator when it parses a record body.
Source: RM5 4.2.1 (p.114); ADV 4.1 (p.64).
R31 - SET base type restrictions¶
Valid base types are ENUMERATION and INTEGER RANGE only.
For an integer set the range must be 0 : x with x <= 255 - the lower bound must be zero and
there is a hard 256-value ceiling.
Wrong: INTEGER RANGE (1:300) SET : s
Right: INTEGER RANGE (0:255) SET : s
Detect: INTEGER\s+RANGE\s*\(\s*(-?\d+)\s*:\s*(\d+)\s*\)\s*SET with lower != 0 or upper > 255.
False positives: bounds written as constant expressions rather than literals cannot be checked
without constant folding - skip those rather than guess.
Source: RM5 4.3.1 (p.122); UG6 p.234, p.192 (IN note: integer sets have INTEGER1 UNSIGNED
base type).
R32 - An unnamed set literal needs its type name in front¶
Wrong: {1,3,5,7,9} =: numbers - RM5 says this "will give a compile error".
Right: tnumbers (1,3,5,7,9) =: numbers where TYPE tnumbers = INTEGER RANGE (0:100) SET
Restriction: such an unnamed constant set "must not be the first statement of a routine, unless the entire statement is contained within parentheses."
Detect: a {/( set-literal at the start of an expression with no preceding type identifier;
and the same construct as the first executable statement of a routine.
False positives: the manual's own examples print these with both {} and (), which is an OCR
inconsistency - PLANC uses parentheses (see R41 and RM5 p.127-128 running text). A brace-based
rule will misfire; match on "a parenthesised comma list where an expression was expected".
Source: RM5 4.3.3 (p.125, p.127).
R33 - POINTER qualification and NIL¶
d-type POINTER : ident [:= p-ident]
Note the ordering trap RM5 1.4 makes explicit:
| Written | Means |
|---|---|
REAL ARRAY POINTER |
a pointer to an array of reals |
REAL POINTER ARRAY |
an array of pointers, each to one real |
NIL is the only value every pointer can hold regardless of its qualification.
Detect: worth a linter note when both ARRAY and POINTER occur in one type expression,
printing which reading applies.
Source: RM5 1.4 (p.53), 3.7 (p.89); UG6 p.206.
R34 - A pointer to an undefined type silently allocates one word¶
norec POINTER : bp % TYPE norec has not yet been defined
Detect: a pointer declaration whose qualification is a name not yet defined by a TYPE
statement earlier in the file (PLANC is single-pass, R57).
False positives: types coming from $INCLUDE files the linter cannot read. Suppress the rule
entirely when includes are unresolved.
Source: RM5 Appendix G restriction 18 (p.346).
R35 - LABEL must be declared, and only locally¶
LABEL : lab1, loop, next
...
lab1 : 1 =: int1
GO lab1
GO refers to them. UG6 p.200: "Labels can only be declared locally
(i.e. inside routines)."
Detect: collect GO <name> targets and LABEL : ... declarations; report a GO to an
undeclared label. Report a LABEL declared at module level.
False positives: a label declared in an outer routine and used in a nested one is in scope.
Scope-blind checking is fine for the "declared nowhere" case only.
Source: RM5 3.9 (p.91), 6.1 (p.154); UG6 p.199-200.
R36 - Predeclaration ?¶
INTEGER : int1?
ROUTINE VOID,VOID : rt2?
? after the name. The real
declaration must follow later in the same module. Used for mutually recursive routines and for
initialising a static linked list of records.
Restrictions: - A predeclared identifier must not appear again in another predeclaration (ILLEGAL PREDECLARATION). - A routine name declared in a predeclaration must not later appear as the identifier in a PROGRAM statement. The compiler does not detect this. - Array bounds in the real declaration must match the predeclaration (ARRAY BOUNDS CONFLICT WITH A PREDECLARATION). - An inner-level routine that is predeclared "will not be executed correctly".
Detect: collect name? predeclarations; report one with no matching later declaration; report
one that later appears as PROGRAM : name; report a predeclared routine declared inside another
routine.
False positives: the real declaration may be in an $INCLUDEd file.
Source: RM5 3.16 (p.98); RM5 Appendix G restrictions 12 and 23ii (p.345, p.347).
R37 - Equivalence = in a declaration¶
INTEGER : int1, int2
REAL : rl1, rl2 = int1
rl2 to begin at the same storage location as int1. Different lengths are NOT checked -
the compiler may emit the warning EQUIVALENCE MAY CAUSE STORAGE CONFLICT (ND-100 only).
Where equivalence is used among record components and initial values are wanted, only the first declaration of the data-element may have an initial value.
Detect: a declaration of the form type : newname = oldname where oldname was declared with a
type of a different SIZE - report a note, not an error. Report an initialiser on a
non-first member of an equivalence group inside a record.
False positives: sizes are machine-dependent (Appendix C), so a size comparison is only
meaningful once a target is fixed.
Source: RM5 3.15 (p.97), 4.2.1 (p.114); UG6 p.144, p.139.
R38 - TYPE b = a where a is a RECORD is illegal and silent¶
TYPE A = RECORD ... ENDRECORD
TYPE B = A % illegal, NO error message
Detect: TYPE\s+(\w+)\s*=\s*(\w+)\s*$ where the right-hand name is a known RECORD type.
False positives: requires knowing the right-hand name is a record. Only fire when the TYPE is
declared in the same file.
Source: RM5 Appendix G restriction 8 (p.344).
R39 - Do not redeclare a name a library include already defines¶
Measured, CODE: CHATSV.PLNC declared its own XFWTF, which XMP-B02:DEFS also defines. The
compiler reported IDENTIFIER ALREADY SPECIFIED/DECLARED and then fell over with
ASSERT VIOLATION AT 136747B, taking the batch job with it. This is not a warning you can walk
past.
Related: XMP-B02:DEFS and XMSG-PL-VALUES-L:INCL share 184 constant names. Including both is 184
redefinitions.
Detect: parse the include files named in $INCLUDE, collect their CONSTANT and declared
names, and report any redeclaration in the main source. planc-lint.py already does exactly this
for XMP-B02-DEFS.readable.txt.
False positives: none, if the include is genuinely being included.
Source: CODE planc-lint.py; RM5 error IDENTIFIER ALREADY SPECIFIED/DECLARED.
R40 - A name that is never declared can still compile clean¶
Measured, CODE: a BOOLEAN stored to and tested in two routines, declared in neither and not at
module level, gave 0 DIAGNOSTICS, and the program ran with it permanently set - printing it
immediately after FALSE =: x showed 1.
This contradicts RM5's NOT PREVIOUSLY DECLARED message, which suggests the compiler ought to catch it. The measured behaviour is what a linter must protect against. Mark this in any report as project-measured on PLANC F, not as manual-documented.
Detect: collect every name on the receiving side of =: (regex
=:\s*([A-Za-z_]\w*)\s*(?![\w(])) and check each against the set of names declared anywhere in the
file - deliberately scope-blind. planc-lint.py implements this.
False positives: names coming from an unreadable $INCLUDE; record components reached via a
USING block (R66), which are declared inside the record TYPE, not at statement level. Make sure
the declaration harvester reads record bodies and INTEGER RANGE (...) : name forms - an earlier
version of this rule missed the RANGE clause and cried wolf.
Source: CODE planc-lint.py, PLANC-DEVELOPER-GUIDE.md.
Section 3 - Operators and expressions¶
R41 - Subscripts use ROUND brackets¶
Wrong: ADDR(buf[0])
Right: ADDR(buf(0))
PLANC F answers EXPECTS ")" ILLEGAL SYNTAX "[" and then INVALID PARAMETER LIST for the enclosing
call. The COSMOS Programmer's Guide prints its samples with square brackets; the typesetting is
wrong and the compiler is right.
Detect: \w\s*\[.
False positives: [ inside a string literal or a comment. Strip both first.
Source: CODE CHAT.PLNC header, planc-lint.py; RM5 4.1.3 (p.105) syntax box.
R42 - Operator precedence table (the whole thing)¶
Higher number binds tighter. Binary operators are left associative. Parentheses override.
| Pr | Operator | Kind | Operand types |
|---|---|---|---|
| 14 | ADDR |
standard routine | any |
| 14 | IND |
standard routine | pointer |
| 13 | . |
binary, record component access | record / record pointer |
| 11 | ** |
binary, exponentiation | left integer or real, right must be integer |
| 11 | ABS |
unary | integer, real; on a SET gives cardinal number |
| 11 | MOD |
binary | integer only |
| 11 | user routines (default) | any | |
| 11 | most standard routines: BIT, BIT_POSITION, BIT_SIZE, BLOCKSIZE, CLOSE, CONVERT, DISPOSE, FILESIZE, FORCE, INPUT, MAXINDEX, MININDEX, MONITOR_CALL, NEW, OPEN, OUTPUT, PRED, SIZE, SUCC, TYPEOF |
||
| 10 | unary - (change sign) |
unary | integer, real |
| 10 | ++, -- |
unary | integer, pointer |
| 9 | *, / |
binary | integer, real |
| 8 | +, binary - |
binary | integer, real |
| 8 | SHIFT |
binary | integer |
| 8 | // [NEWER ONLY] |
binary, concatenation | BYTES |
| 7 | : (range designator) |
binary | integer, enumeration, pointer |
| 6 | =, ><, >, <, >=, <= |
binary, relational | see R45 |
| 6 | , (list item separator) |
binary | any listable |
| 5 | IN |
binary | integer/enumeration/pointer against set or range |
| 5 | INSERT, APPEND, REMOVE |
standard routines | |
| 4 | NOT |
unary | integer, Boolean, set |
| 3 | AND |
binary | integer, Boolean, set |
| 2 | OR, XOR |
binary | integer, Boolean, set |
| 1 / 12 | =: store, :=: swap |
binary | see R43 |
| 1 | RETURN, ERRETURN |
Note the two-sided priority of the assignment operators. RM5 5.1: the left side has priority 1
(the lowest possible, so everything to the left is evaluated first) and the right side has priority
12 (so the target expression on the right binds tightly). This is why 1+2 =: int1+4 =: int2
stores 3 into int1 and 7 into int2.
Detect: a linter that reformats or evaluates expressions must implement this table exactly. More usefully: emit a note on any expression mixing three or more different priority levels without parentheses. Source: RM5 5.1 (p.138), 5.2 (p.141), 5.3 (p.145), 5.4 (p.148), 7.10 (p.204-207); UG6 p.129-135 summary table and the per-operator entries p.143-232.
R43 - =: and :=: semantics differ in what they leave behind¶
a =: b stores a into b, and the value of the expression is unchanged - still a.
a :=: b stores a into b, and the value of the expression becomes b's previous value.
3 =: int
4 :=: int % int is now 4, the expression's value is 3
3 =: i ; 4 =: j
i :=: j =: i % exchanges i and j
Detect: nothing to flag directly. But a linter reading x :=: y where the result is discarded
should note that the swap's whole point is the returned value.
Source: RM5 5.1 (p.138-140); UG6 p.141.
R44 - :=: works only on simple types¶
RM5 5.1 operator table: store =: accepts "all simple, composite and predefined"; change :=:
accepts "all simple" only. UG6 p.141 repeats: "only simple types can be used, while the =: works
with both simple and composite types."
Detect: :=: where either operand is a known ARRAY, RECORD, SET or BYTES variable.
Source: RM5 5.1 (p.138); UG6 p.141.
R45 - Not-equal is ><¶
Wrong: !=, <>, /=, =<, =>
Right: ><, and <=, >=
=< is especially nasty: it gets ILLEGAL SYNTAX, but the compiler blames the line above first
with ILLEGAL DATA TYPE "AND", so the operator is the last thing anyone looks at.
UNVERIFIED: UG6's SET entry (p.234) lists <> among the set test operators alongside ><.
Every other place in both manuals, and all working code, uses ><. Treat <> as an error and note
the discrepancy rather than accepting it.
Detect: !=|<>|/=|=<|=> outside strings and comments.
False positives: => cannot appear legitimately. <> could in theory be < followed by > in
a bizarre expression - vanishingly unlikely. /= cannot appear because / and = would need an
operand between them.
Source: RM5 2.3 (p.68), 5.4 (p.148); CODE planc-lint.py.
R46 - There is no automatic conversion between base types¶
An expression must contain operands of one data type only. Integer and real do not mix; pointer and integer do not mix.
Right: int CONVERT REAL =: rl
CONVERT changes value representation between integer and real types/subtypes.
FORCE reinterprets the bits and requires exactly the same size - a size mismatch gives
ILLEGAL DATA-ELEMENT TO BE CONVERTED. Subtypes of the same base type (INTEGER1 vs INTEGER4, REAL
PRECISION 7 vs 15) DO mix, and the result takes the larger of the two.
AND IT IS A WARNING, NOT AN ERROR - the program links, runs, and answers something plausible.
MEASURED 2026-08-27 while establishing whether a routine may take ADDR of a buffer it was handed:
668/XRADDROF *** WARNING - ILLEGAL DATA-ELEMENT TO BE CONVERTED
ADDR(buf(0)) FORCE INTEGER4 on an ND-100 address, which is SIXTEEN BITS, is exactly this size
mismatch. The build did not stop. The routine returned 3473663 where the caller's own ADDR said
65791, and it did so reproducibly across two separate builds - which reads as a measurement
and is not one. Two attempts were published and withdrawn before the warning in the listing was
read.
Written as FORCE to a 16-bit type it draws no diagnostic at all and the two addresses agree.
So: the diagnostic that mattered was in the listing the whole time, it was only a warning, and
a warning here invalidates a result as thoroughly as an error would - see R110.
Detect: a binary arithmetic or relational operator with one operand of known integer type and
the other of known real type. Requires a type table; only attempt it for variables declared in the
same file.
False positives: overloaded operators. If the file declares ROUTINE REAL, REAL (INTEGER) : +
then mixed + is legal. Collect user routine declarations whose name is an operator and suppress
the rule for that operator.
Source: RM5 5 intro (p.136), 5.5 (p.150-151); UG6 p.161-162, p.174, p.126-128.
R47 - Integer division keeps no remainder; use MOD¶
27 MOD 5 = 2, -27 MOD 5 = -2, 27 MOD -5 = 2, -27 MOD -5 = -2.
The sign follows the left operand.
Detect: nothing to flag. Worth knowing when constant-folding. Source: RM5 5.2 (p.141-142).
R48 - SHIFT direction, and a version disagreement about fill¶
Positive second operand = shift left; negative = shift right.
- RM5 5.2 (p.142): for a signed integer the sign bit is not affected by left shifts and is extended for right shifts; for an unsigned type (a non-negative integer range) zeroes come in.
- UG6 p.235: "filling vacant bits with zeros", with no signed/unsigned distinction.
These are not the same statement. Do not assert either as fact for an arbitrary compiler version.
A linter may emit a note on SHIFT with a negative right operand and a signed left operand,
saying the fill behaviour is version-dependent.
Source: RM5 5.2 (p.142) vs UG6 p.235. Genuine conflict, marked as such.
R49 - ABS of a SET is the MAXIMUM possible members, not the count¶
colour SET : bright := (red, green, yellow, pink) % 4 members
ABS bright =: int1 % int1 = 8, the base type's size
Detect: ABS applied to a known SET variable - emit a note.
Source: RM5 4.3.3 (p.126), 5.3 (p.145).
R50 - Logical operators on integers are bitwise, on Booleans are logical¶
AND, OR, XOR, NOT accept integer, Boolean or set operands. With integer operands the
operator applies to every bit. With sets: AND = intersection, OR = union, XOR = difference,
NOT = complement.
Both operands must be the same kind. Mixing a Boolean and an integer is an error.
Detect: AND/OR/XOR with one operand a known BOOLEAN and the other a known INTEGER.
False positives: overloading (see R46).
Source: RM5 5.3 (p.145-147); UG6 p.147, p.202, p.232.
R51 - Expression evaluation order is not guaranteed, and parts may not be evaluated at all¶
RM5 5.3: "the actual order of interpretation is not fixed so long as the result is mathematically and logically equivalent. Indeed it can happen that part of an expression is not evaluated at all."
IF ( i = 1 OR 1.5 + i =: r > 10.1 ) THEN ...
i is 1, nothing is stored into r.
Detect: a =: store appearing inside the condition of an IF, WHILE, ASSERT or a CASE
selector, on the right of OR/AND. Report as a warning: the store may not happen.
False positives: the store on the FIRST operand of the expression always happens. Only flag
stores that appear after an AND/OR.
Source: RM5 5.3 (p.145).
R52 - . reaches through pointers implicitly¶
r POINTER : rp := ADDR(rec)
rp.i =: k % same as ind rp.i =: k
rec.element, recp.element and
ADDR rec.element all reach the same data-element.
A pointer declared to a base record type may point to a variant and its variant components can be reached through it - the compiler accepts it, and if the pointed-to object is really the base type, memory outside the record gets written. UG6 p.227 calls this out as a source of hard-to-find errors.
Detect: flag a component name reached through a pointer where the component belongs to a variant of the pointer's declared type, not to the declared type itself. Needs a record-type table; only feasible in-file. Source: RM5 4.2.3 (p.117), 4.2.2 (p.116); UG6 p.182, p.219, p.227.
R53 - MAXINDEX/MININDEX dimension must be a literal or constant¶
Wrong: MAXINDEX(a, dimVar)
Right: MAXINDEX(a, 1)
RM5 7.9: "the dimension number must be an integer literal, it cannot be an identifier or an
expression."
[NEWER ONLY] the dimension may be omitted for a one-dimensional array: MaxIndex(A).
Detect: (MAXINDEX|MININDEX)\s*\(\s*\w+\s*,\s*([A-Za-z_]\w*) where the second argument is not
a declared CONSTANT. Also flag the one-argument form when targeting version G.
False positives: a CONSTANT identifier is acceptable per the 7.10 table ("integer liter. or
constant"). Resolve CONSTANT names first.
Source: RM5 7.9 (p.200), 7.10 (p.206); ADV 4.1 (p.61).
R54 - MININDEX/MAXINDEX/IN cannot take a formal parameter inside a STANDARD routine¶
Inside a routine declared with the STANDARD modifier, these three standard routines cannot have
one of the routine's own formal parameters as an actual parameter. "Note that the compiler does
not detect this condition or give any error message."
Detect: inside a ROUTINE STANDARD ... body, a MININDEX/MAXINDEX/IN whose operand is one
of that routine's parameter names.
False positives: none.
Source: RM5 Appendix G restriction 5 (p.344); RM5 7.1 (p.178).
Section 4 - Control flow¶
R55 - IF¶
IF expr THEN
stmts
[ELSIF expr THEN
stmts]...
[ELSE
stmts]
ENDIF
ELSIF, not ELSEIF or ELIF. The condition must have a Boolean resulting
value. ENDIF is mandatory.
Detect: \bELSEIF\b|\bELIF\b (note: $ELSIF is the compiler-command form and is correct with
the dollar). Unbalanced IF/ENDIF counting per routine.
False positives: ENDIF also terminates $IF conditional compilation - but that form is
$ENDIF. Count the two families separately by whether the token starts with $.
Source: RM5 6.2 (p.155); UG6 p.190.
R56 - There is no ENDWHILE, and WHILE cond DO is not a loop header¶
Wrong:
WHILE x > 0 DO
...
ENDWHILE
DO
...
WHILE x > 0
...
ENDDO
WHILE is a continue-test placed inside a DO ... ENDDO or FOR ... ENDFOR loop. When the
condition is TRUE control passes to the statement after the WHILE; when FALSE the loop is left. It
may appear anywhere in the loop body, and there may be any number of WHILE statements in one
loop.
Detect: \bENDWHILE\b; and \bWHILE\b.*\bDO\s*$.
False positives: none.
Source: RM5 6.6 (p.167); UG6 p.177, p.242; CODE planc-lint.py.
R57 - DO ... ENDDO with no exit is an infinite loop¶
RM5 6.4: "At least one GO statement must be in the group of statements to leave the loop under some
condition. If not the program will contain an infinite loop." UG6 adds the other legitimate exits:
RETURN, ERRETURN, ASSERT, GO, a WHILE condition, or a call to an exit routine.
Real cost, CODE: "a bare loop with no sleep is not an option. When XMSG died under the server, its receive stopped blocking and the loop burned the machine until somebody pressed ESC."
Detect: a DO ... ENDDO block containing none of WHILE, GO, RETURN, ERRETURN and no
routine call. Report as a warning.
False positives: a deliberate event loop that exits by monitor call. Warning only.
Source: RM5 6.4 (p.160); UG6 p.177; CODE CHAT.PLNC.
R58 - FOR¶
FOR control-ident IN [REVERSE] list DO
stmts
[WHILE expr
stmts]...
[EXITWHILE
stmts]
[EXITFOR
stmts]
ENDFOR
The list may be: explicit integer/enumeration/pointer values; implied ranges a:b; one or more
one-dimensional arrays; or one or more pointer implied ranges head:linkfield.
Rules a linter can check:
- The control identifier's data type must match the list values (ILLEGAL CONTROL IDENTIFIER).
- If the control identifier is a pointer and the list contains arrays, only one array is
permitted in the list.
- A pointer control identifier must not be used with a PACKED array whose elements are smaller
than the smallest addressable unit (e.g. INTEGER1 PACKED on the ND-100) - "unpredictable
results".
- Only one EXITFOR and one EXITWHILE per loop
(EXITFOR/EXITWHILE ALREADY PRESENT WITHIN THE LOOP).
- REVERSE applies to implied ranges and to arrays. It may not be used with a pointer implied
range. Implied ranges must still be written in ascending order.
- Multi-dimensional arrays are not allowed in the list
(MULTIDIMENSIONAL ARRAY NOT ALLOWED HERE).
Detect: count EXITFOR/EXITWHILE per FOR block; check REVERSE against a list containing :
between two pointer names; check array dimensionality of names in the list.
False positives: telling a:b (integer range) from head:next (pointer implied range) needs
the declared types. When unknown, skip.
Source: RM5 6.5 (p.161-163), 6.6 (p.167); RM5 error messages; UG6 p.184-186.
R59 - After a FOR loop the control variable is unpredictable¶
"Upon exit from a FOR - ENDFOR loop, the control identifier will have an unpredictable value. This
applies as soon as the loop exit action begins" - including on entry to the EXITFOR block.
The one documented exception: a pointer implied range loop leaves the control pointer as NIL
if the loop ended by exhausting the list.
Detect: a read of the control identifier after ENDFOR, or inside an EXITFOR block, when the
list was not a pointer implied range. Warning.
False positives: the variable may have been re-stored between the ENDFOR and the read.
Only flag the first read.
Source: RM5 6.5 (p.161, p.163).
R60 - FOR list expressions are evaluated once, at loop initialisation¶
"Expressions are evaluated at runtime within the loop initialization so that modifying identifiers used in such an expression during execution of the loop will have no effect on the control of the loop."
But: with an implied range, altering the control identifier inside the loop DOES affect loop control - setting it >= the range's final value terminates that range.
Detect: a store into the control identifier inside a FOR body - warning, quoting whichever of the two behaviours applies. Source: RM5 6.5 (p.162-163).
R61 - CASE¶
CASE expr
INCASE value-list
stmts
[INCASE value-list
stmts]...
[ELSE
stmts]
ENDCASE
Hard constraints:
- expr must be an enumeration of at most 256 values, or an integer in 0..255.
- If the values belong to an INTEGER RANGE, the lower bound of that range must be 0.
- A value must not appear in more than one INCASE list in the whole CASE
(INCASE CONTAINS INVALID VALUE).
- If the INCASE lists do not cover every possible value, an ELSE must be present
(REQUIRE ELSE OR ALL POSSIBLE VALUES USED IN INCASE PARTS).
- The values in each INCASE must be the same data type as expr.
- An INCASE list may be an implied range: INCASE monday : thursday.
Measured, CODE: a plain INTEGER as the CASE selector draws OUT OF RANGE - which is a
WARNING, so the build "succeeds" and the CASE misbehaves at run time.
Detect: CASE\s+(\w+)\s*$ where the named variable is not declared
INTEGER RANGE (0:...) and is not an enumeration. planc-lint.py implements this. Also: collect
INCASE values per CASE block and report duplicates; report a missing ELSE when the selector is a
plain integer.
False positives: the selector may be an expression rather than a bare name - the regex only
handles the bare-name case, which is the common one. Enumeration-typed selectors need the type
table.
Source: RM5 6.3 (p.158-159); UG6 p.167-168; CODE planc-lint.py.
R62 - GO into a structured block is unpredictable¶
RM5 6.1: "control transfers into structures such as FOR - ENDFOR or DO - WHILE - ENDDO loops may have unpredictable results."
Loops and IF blocks must nest completely: "statements such as DO - ENDDO and IF - ENDIF must be entirely contained within the FOR - ENDFOR loop."
Detect: a GO label where the label is defined inside a loop or IF block that does not contain
the GO. Requires block tracking - straightforward with a bracket-matching pass.
False positives: none.
Source: RM5 6.1 (p.154), 6.5 (p.163).
R63 - ASSERT¶
ASSERT boolean-expr. If FALSE, the ASSERTFALSE exception is raised. With no
ON ASSERTFALSE handler, the program terminates with ASSERT VIOLATION AT <address> - the runtime
routine invoked is 5FATA on ND-100 / #FATA on ND-500.
Detect: an ASSERT in a file with no ON ASSERTFALSE handler anywhere - note, not error. A
production server should not carry an ASSERT that can kill it.
Source: RM5 6.7 (p.170), 6.8 (p.172); UG6 p.150.
Section 5 - Routines¶
R64 - The routine header¶
ROUTINE [modifier...] in-type, out-type [(param-types)] : name [(param-names)] [ALIAS 'x']
...body...
ENDROUTINE [name]
ROUTINE [modifier...] in-type, out-type (type : names; type : names) : name
Both the in-value type and the out-value type are mandatory. VOID denotes absence. There is
no shorthand for "no values" - it is written ROUTINE VOID,VOID.
ENDROUTINE name (naming the routine at its end) is [NEWER ONLY].
Detect: ^\s*ROUTINE\b without a comma in the header is malformed. ENDROUTINE\s+\w+ when
targeting version G.
False positives: the header may be split across & continuation lines - join first. The
IMPORT ( ROUTINE ... ) form is a declaration, not a definition, and has no body.
Source: RM5 7.1 (p.175-176); UG6 p.229-231; ADV 4.1 (p.60).
R65 - The two parameter-list styles, and the semicolon in the newer one¶
Classic (works everywhere): types in one list, names in another.
ROUTINE VOID,VOID (INTEGER,INTEGER,INTEGER WRITE) : simple(in1,in2,outval)
[NEWER ONLY] types and names together, semicolon-separated groups:
ROUTINE VOID, VOID (INTEGER : a, b, c; BOOLEAN : d, e) : x
Detect: count comma-separated items in the type list against the name list. For the newer
form, flag a comma where a semicolon should separate two type : names groups.
False positives: a type expression can itself contain a comma inside RANGE (a:b)? No - RANGE
uses a colon. But ARRAY bounds do not appear in a parameter type. Nesting depth counting is still
the safe way.
Source: RM5 7.1 (p.175); UG6 p.154, p.230-231; ADV 4.1 (p.60), 4.2 (p.67).
R66 - Parameters default to READ¶
The default access mode for a formal parameter is READ only. To return a value through a
parameter you must write WRITE or READ WRITE.
READ on a parameter is therefore redundant (UG6 p.224).
Detect: a parameter that is stored into inside the routine body but declared without
WRITE. Requires matching parameter names to their type-list position.
False positives: a parameter of a composite type (ARRAY, RECORD) is passed by reference
(R70), so storing into its components works regardless of the access modifier. Only flag simple
types.
Source: RM5 7.1 (p.176), 7.4 (p.190); UG6 p.224, p.243, p.138.
R67 - A WRITE-only parameter needs an explicit variable, never an expression¶
Wrong: twice(3+2*5) where the formal is INTEGER READ WRITE
Right: twice(int) or twice(3 =: int)
RM5 7.4: "any invocation of a routine with any WRITE only parameters must have explicit actual parameter data-elements for such parameters. Expressions are invalid as actual parameters." The compiler answers INVALID ACTUAL PARAMETER, FORMAL PARAMETER DECLARED AS WRITE.
Also: a WRITE-only (not READ WRITE) formal has an undefined value on entry to the routine.
Detect: a call whose argument at a WRITE position is not a bare name or a name with subscripts.
False positives: f(3 =: int) is legal and contains an operator - accept an argument whose
last operation is a store into a variable.
Source: RM5 7.4 (p.190-192); RM5 error INVALID ACTUAL PARAMETER.
R68 - @ is the in-value, and it cannot be stored to¶
Inside a routine with a non-VOID in-value, @ names it. For a composite in-value the component
follows normally: @.realpart. In nested routines @ refers to the innermost routine's
in-value.
UG6 p.146: "It can be used in expressions just like any other variable, with the exception that it cannot be stored to."
Detect: =:\s*@ or @\s*:=. Also: @ used in a routine declared ROUTINE VOID, ....
False positives: @ inside a string literal (SINTRAN command lines contain it constantly -
@RT CHATSER). Strip strings.
Source: RM5 7.2 (p.180); UG6 p.146.
R69 - The out-value goes BEFORE RETURN¶
Wrong: RETURN answer
Right: answer RETURN
The compiler answers ILLEGAL DATA TYPE "RETURN", which reads as though RETURN itself is the
problem. Same shape for ERRETURN: errorNumber ERRETURN.
A routine declared with a non-VOID out-value must contain at least one RETURN
(ROUTINE WITH AN OUT-VALUE REQUIRES A RETURN), and exit through a bare ENDROUTINE is then
illegal. A routine with VOID out-value may fall off the end into ENDROUTINE.
ERRETURN's expression must be INTEGER, and its value lands in ERRCODE.
Detect: ^\s*RETURN\s+\S (after comment stripping) - the value is on the wrong side. Also:
a routine whose out-type is not VOID and whose body contains no RETURN.
False positives: RETURN % comment looks like RETURN <something> unless comments are
stripped first, respecting quotes. planc-lint.py gets this right.
Source: RM5 7.5 (p.193); UG6 p.216, p.182; CODE planc-lint.py.
R70 - Simple parameters go by value, composite by reference¶
Simple types (INTEGER, REAL, BOOLEAN, ENUMERATION, POINTER) are copied into a temporary local data-element. Composite types (ARRAY, RECORD; and by extension BYTES, BITS, SET) are passed as an address - so changes made by the callee are visible to the caller immediately, regardless of access modifiers.
In-values and out-values of composite type are also passed by reference.
Consequence to flag: a WRITE modifier on a composite parameter is misleading, and the write-back timing described in R71 does not apply to it. Source: RM5 7.4 (p.190-192), 7.2 (p.181); UG6 p.138.
R71 - A WRITE parameter's value is transferred back only on a NORMAL exit¶
The temporary local is copied back to the actual parameter after the routine returns normally.
"Such transfers will not take place if an abnormal routine exit occurs" - i.e. after ERRETURN, or
after a ROUTINEERROR handler's ENDON.
Consequence: after an ON ROUTINEERROR handler runs, "an out-value data-element or actual
parameter data-elements with WRITE access would contain unpredictable values."
Measured, CODE: this is exactly why CHAT.PLNC keeps firstErr at module level - "a PLANC
local cannot be read after its ON block has run, and the number is the whole point of the attempt."
(Project-measured phrasing; the manual states the parameter/out-value case, not the local case, so
treat the local variant as project-observed, UNVERIFIED against the manual.)
Detect: a read of a WRITE parameter or of a routine's out-value on a path that can only be
reached after a ROUTINEERROR handler - hard to do statically. More practical: emit a note
whenever an ON ROUTINEERROR handler stores into a routine-local variable that is read after
ENDON, recommending module level instead.
Source: RM5 7.4 (p.190), 6.8 (p.171), 7.5 (p.194); CODE CHAT.PLNC.
R72 - Parentheses may be omitted for a single parameter¶
twice 5 and twice(5) are the same. Worth knowing so a linter does not treat a bare
name expr as an error.
Source: RM5 7.3 (p.183).
R73 - A bare name on a line of its own must actually BE a routine¶
This one shipped a broken client for days (CODE). CHAT.PLNC contained:
setMyName(at, argLen)
showMyName <- no such routine, anywhere in the file
sendJoin
2494 (1834)/HANDLECOMM *** ERROR - ILLEGAL SYNTAX "SHOWMYNAME"
0 DIAGNOSTICS left on screen belongs to the linker, sitting happily
under a compile that failed. The listing file is the only place a PLANC error survives.
A call with no parameters is just the name, so a typo is indistinguishable by eye. This is exactly what a machine should check.
Detect: a code line matching ^\s*([A-Za-z_]\w*)\s*$ whose name is not a statement keyword and
is not declared anywhere in the file.
False positives: a line that is an ARGUMENT of a call continued from the previous line with
& - skip a line whose predecessor ended in &. Statement keywords must be excluded:
RETURN ENDIF ENDDO ENDFOR ENDON ENDROUTINE ENDMODULE ENDRECORD ENDCASE ENDUSING ELSE DO THEN GO
EXITFOR EXITWHILE. Labels (name :) are not bare names. Names from $INCLUDEd files.
Source: CODE planc-lint.py, CHAT.PLNC.
R74 - PLANC is single-pass: declare or predeclare before use¶
A routine called above its declaration gets ILLEGAL SYNTAX on the call, naming a routine that plainly exists - which reads as nonsense.
Right: either move the declaration up, or predeclare it: ROUTINE VOID,VOID : rt2?
Detect: for each ROUTINE/PROGRAM declaration at line N, any use of that name before line N
that is not itself a declaration or a predeclaration.
False positives: a name that is both a routine and (say) a record component. planc-lint.py
accepts this looseness deliberately.
Source: RM5 3.16 (p.98); CODE planc-lint.py.
R75 - Routine modifiers, and what each one breaks¶
| Modifier | Effect | The trap |
|---|---|---|
INLINE |
body expanded at each call site | cannot be declared or invoked inside another INLINE routine (ILLEGAL INLINE INVOCATION); ON ROUTINEERROR does not work correctly inside an INLINE routine; cannot recurse |
SPECIAL |
no entry/exit sequence at all | cannot recurse; a SPECIAL routine that uses stack space draws LOCAL/TEMPORARY VARIABLES REQUIRE STACK SPACE IN |
STANDARD |
FORTRAN/COBOL calling sequence | in-values are not allowed; MININDEX, MAXINDEX and ERRETURN are not available; array parameters should have lower bound 0 |
REFERENCE |
all parameters by address | inner (nested) REFERENCE routines are not executed correctly - outermost level only; array parameters should have lower bound 0 |
[NEWER ONLY] C, NATIVE, COBOL, FORTRAN, PASCAL, MAINSTART, XARGS, PARALLEL, DOMAIN |
see UG6 |
Inner routines declared STANDARD, REFERENCE or SPECIAL "will not be executed correctly".
Detect: ROUTINE\s+(\w+\s+)*STANDARD\b with a non-VOID in-type; an ON ROUTINEERROR inside an
INLINE body; an INLINE call inside an INLINE body; a STANDARD/REFERENCE/SPECIAL routine declared
between another routine's header and its ENDROUTINE.
False positives: none.
Source: RM5 7.1 (p.178-179), 7.7 (p.195); RM5 Appendix G restrictions 11 and 23 (p.345, p.347);
UG6 p.183, p.215, p.223-224.
R76 - Recursion is only allowed at the outer level of a module¶
Direct recursion: a routine may invoke itself only if it is declared at the outermost level of a module. A routine nested inside another routine must not invoke itself.
Indirect recursion is allowed at any nesting level, provided the chain goes via an outer-level routine.
SPECIAL and INLINE routines cannot recurse at all.
Stack overflow from recursive calls never invokes an exception block (RM5 6.8) - it terminates
the program with STACK OVERFLOW AT <address>.
Detect: a routine declared inside another routine whose body contains its own name. False positives: a name shadowed by an outer routine of the same name - illegal anyway (R113). Source: RM5 7.7 (p.195-196), 6.8 (p.171); RM5 Appendix G restriction 23i (p.347).
R113 - An identifier may not be redeclared inside a nested routine¶
An identifier declared in a routine has the scope of the entire routine, and "such identifiers may not have an identifier name which is identical to an identifier whose scope includes this routine, i.e. an identifier may not be declared twice within nested routines."
Wrong:
ROUTINE VOID,VOID : outer
INTEGER : counter
ROUTINE VOID,VOID : inner
INTEGER : counter % illegal - outer's counter is in scope here
ENDROUTINE
ENDROUTINE
The one documented exception: an INLINE routine MAY use local names that are the same as names
whose scope includes the call site, even though the body is textually inserted there.
Two routines at the same level may of course each declare i and j - those are different
data-elements (RM5 1.10).
Detect: track routine nesting; report a declaration in an inner routine whose name (folded to
ten characters, R4) is already declared in an enclosing routine or at module level. Skip routines
declared INLINE.
False positives: names arriving from $INCLUDE. Also record component names, which live in the
record's own scope and are reachable by USING - do not treat those as module-level declarations.
Source: RM5 7.8 (p.197), 1.10 (p.63).
R77 - Routines cannot be in-values, out-values or parameters¶
INVALID TYPE FOR IN-VALUE/OUT-VALUE/PARAMETER: "The data type of a routine in-value, out-value or parameter must not be a routine. Note that a pointer to a routine data-element may be used."
[NEWER ONLY] exception: co_Call, co_Detach and co_Resume are "the only ones in PLANC that
have routines as parameters".
Detect: ROUTINE appearing inside a routine header's in-type, out-type or parameter type list.
False positives: TYPE x = ROUTINE ... is a type specification, not a header.
Source: RM5 error INVALID TYPE FOR ...; UG6 p.218.
R78 - ADDR of a routine takes no parentheses¶
Wrong: ADDR(myfirst) where myfirst is a routine
Right: ADDR myfirst
Also: if several routines in a module share a name, ADDR returns the address of the first
one declared. And ADDR of a routine that has an out-value yields the routine's address, not its
out-value.
ADDR(ADDR(an ARRAY data-element)) does not work; use two statements with an explicit
ARRAY POINTER.
Detect: ADDR\s*\(\s*(\w+)\s*\) where the name is a declared routine. ADDR\s*\(\s*ADDR\b.
False positives: none.
Source: RM5 7.9 ADDR (p.197); RM5 Appendix G restrictions 3, 4, 24 (p.344, p.347).
R79 - IND restrictions¶
IND cannot take a pointer that qualifies a routine with an in-value.
Nested routine invocations must not be carried out with IND; IND may only invoke routines at
the outer level of a module.
An inner-level routine invoked via IND "will not be executed correctly".
Detect: IND\s+(\w+) / IND\s*\(\s*(\w+) where the pointer's qualification is a routine type
declared with a non-VOID in-value, or a routine declared inside another routine.
Source: RM5 7.1 (p.179), 7.3 (p.183); RM5 Appendix G restrictions 2, 23ii (p.344, p.347).
R80 - An overload family must differ in TYPES, not just access modifiers¶
Wrong:
ROUTINE VOID, VOID (INTEGER) : RUT?
ROUTINE VOID, VOID (INTEGER WRITE) : RUT?
Also: the number of parameters must be the same across a family (UG6 p.126: "A new routine that overloads existing ones is only accepted into the set if the number of variables in the parameter is the same as in the existing routines").
And: it is illegal to EXPORT a family whose name is the same as a predefined standard routine or operator.
Detect: two routine declarations with the same name whose parameter type lists differ only in
READ/WRITE tokens; two with the same name and different parameter counts; an EXPORT of a name
that is a standard-routine or operator name.
Source: RM5 Appendix G restrictions 7 and 9 (p.344-345); RM5 8.3 (p.211); UG6 p.126.
R81 - Ignoring a routine's out-value is silent¶
"It is legitimate in an invocation of a user written routine, which is declared with an out-value, not to store the out-value. The compiler will not give any warning or error messages."
Detect: a call to a routine with a non-VOID out-type appearing as a whole statement with no
=: and no enclosing expression. Warning at most - it is legal, and sometimes intended.
Source: RM5 Appendix G restriction 17 (p.345).
R82 - i =: rtn =: j does not store the routine's out-value¶
If a routine has both an in-value and an out-value and is invoked with an assignment operator
immediately before AND after it, "then the value of i will be the value stored in j, not the
out-value of the routine invocation."
Detect: =:\s*(\w+)\s*=: where the middle name is a declared routine. Warning.
Source: RM5 7.3 (p.183).
Section 6 - Module and program structure¶
R83 - MODULE¶
MODULE name
[EXPORT ...]
[IMPORT ...]
[TYPE ...] [CONSTANT ...]
declarations, routines, PROGRAM
ENDMODULE
EXPORTstatements must be placed immediately following the MODULE statement, before all other declarations. TYPE specifications and IMPORT statements may precede an EXPORT.- Only identifiers global in the module may be EXPORTed (IDENTIFIER IN EXPORT, BUT NO DECLARATION).
- A module must be terminated by
ENDMODULE(ILLEGAL MODULE TERMINATION). - Module nesting is limited (TOO MANY LEVELS OF MODULE NESTING; UG6 says 16).
- If modules are nested, routines and executable code may only be in the innermost module - except that two separate nests inside one outer module may each have code in their innermost.
TYPEandCONSTANTstatements may appear outside/before any module, in which case their identifiers are global to all modules in the compilation and need no IMPORT.
Detect: an EXPORT line appearing after a data declaration inside the same module. An
ENDMODULE count that does not match MODULE. A ROUTINE/PROGRAM in a module that contains a
nested MODULE.
False positives: MODULE inside a string or comment.
Source: RM5 8.1 (p.208-209), 8.3 (p.211), 8.5 (p.220-221), 8.7 (p.223); UG6 p.206-208.
R84 - EXPORT / IMPORT¶
EXPORT [(SYSTEM)] ident[,ident]...
IMPORT [(SYSTEM)|(COMMON)] declaration[,declaration]...
- If
(SYSTEM)is on the EXPORT it must also be on the matching IMPORT, and vice versa. (COMMON)links to a named FORTRAN COMMON block.- If declarations of different data types go in one IMPORT, each must be in parentheses:
IMPORT (INTEGER : i1,i2),(REAL : r1,r2),(BOOLEAN : b1) - A routine's IMPORT declaration must be in parentheses, and must not include the formal
parameter names:
IMPORT ( ROUTINE VOID,VOID (INTEGER) : doit ) - The IMPORT declaration must match the exporting module's declaration (CONFLICTING DATA TYPES IN CORRESPONDING IMPORT/EXPORT). This is only checked at compile time when the two modules are nested inside a common outer module; separately compiled modules are not checked at all.
- An identifier must be IMPORTed at every level between the declaring module and the using one.
- An identifier created in an outer module by an IMPORT needs an identical IMPORT in the nested module.
- TYPE and CONSTANT identifiers are IMPORTed into a nested module without any EXPORT in the outer module.
Detect: IMPORT\s*\(\s*ROUTINE[^)]*\)\s*\( - parameter names left in. An IMPORT of a routine
without surrounding parentheses. An IMPORT list mixing types without per-item parentheses.
False positives: parentheses inside the parameter type list confuse a naive matcher - count
depth.
Source: RM5 8.3 (p.211-213), 8.5 (p.220), 8.7 (p.223); UG6 p.178-180, p.183-184.
R85 - PROGRAM¶
PROGRAM : name
declarations
INISTACK stackarray
statements
ENDROUTINE
- The header is exactly
PROGRAM : name- no in-value, out-value or parameters. - It is terminated by
ENDROUTINE, not byENDPROGRAM. - Exactly one PROGRAM (or
ROUTINE MAINSTART[NEWER ONLY]) per executable program. - A name used in a routine predeclaration must not later appear as a PROGRAM identifier (R36).
Detect: \bENDPROGRAM\b is not a PLANC keyword. PROGRAM\s*: followed by parentheses or a
comma. More than one PROGRAM : in a compilation unit.
Source: RM5 8.2 (p.210); UG6 p.209, p.207.
R86 - INISTACK is mandatory and its array is constrained¶
INISTACK int-array
The array must be: - of type INTEGER - not a subtype, not INTEGER2/INTEGER4 (UG6 p.195) - one-dimensional - with lower index bound zero - global or imported - not a routine local - not a subrange (UG6 p.217)
"However simple a program may be, the INISTACK standard routine must appear in the main program before any other routines are called."
The compiler messages are INISTACK INVOCATION MISSING and INVALID ARRAY FOR INISTACK INVOCATION.
A second INISTACK inside a routine switches to a new stack for that routine and everything it calls, and reverts on RETURN. That is a real feature, not an error.
Beware the OCR: RM5 and UG6 both print this word as INISTACK, INITSTACK, INSTACK and
INVSTACK in different examples. INISTACK is the correct spelling - it is the one in the
keyword/standard-routine tables (RM5 2.3 p.68, 7.9 p.199) and the one that compiles (CODE).
Detect: a PROGRAM : block with no INISTACK. An INISTACK whose array is declared with a
non-zero lower bound, more than one dimension, a non-INTEGER type, or inside a routine. Any of the
misspellings INITSTACK|INSTACK|INVSTACK.
False positives: none.
Source: RM5 1.2 (p.51), 8.6 (p.222), 7.9 (p.199); RM5 error messages; UG6 p.194-195, p.217;
CODE.
R87 - Static vs dynamic allocation decides what can be initialised¶
Statically allocated: global data-elements in a basic MODULE; locals declared READ only;
data made by NEW inside a global data-element.
Dynamically allocated (on the stack): locals whose access is not READ only; data made by NEW
on the stack or inside a local.
"A static data-element may be initialized with a specific value, in its declaration, provided that it is not within a nested routine. Static data-elements may be initialized within a nested routine if it is declared as READ only."
All data-elements in a routine's local data area, including dynamically created ones, are lost when the routine exits.
Detect: this is the mechanism behind R20 - same check. Source: RM5 8.6 (p.221-222), 4.5 (p.130).
R88 - $INCLUDE placement¶
Wrong: $INCLUDE above the MODULE line, when the included file declares things the module
body needs.
Measured, CODE: placed above MODULE the declarations are compiled into the outer scope, the
module body cannot see them, and every call fails with NOT PREVIOUSLY DECLARED "XMPFOPN" while
the include's own lines draw no diagnostic at all - so the declarations look accepted and are
simply not there. 33 diagnostics, none of them on the include.
Right: $INCLUDE after the MODULE statement.
(TYPE and CONSTANT declarations are the exception - RM5 8.5 explicitly recommends $INCLUDEing a
TYPE-only file outside all modules so its types are global. So the rule is about declarations,
not about include position in general.)
Detect: a $INCLUDE line before the first MODULE line. planc-lint.py implements this.
False positives: a genuine TYPE/CONSTANT-only include. Downgrade to a warning naming the file.
Source: CODE CHAT.PLNC, planc-lint.py; RM5 8.5 (p.220), Appendix A 0.8 (p.252).
R89 - $INCLUDE nesting limit and $EOF¶
No more than 16 incomplete $INCLUDEs at any one time.
$EOF ends the current file and returns to the enclosing include level.
$IF ... $ENDIF groups inside included text must be complete before the include ends.
$IF may nest to 11 levels.
$EOF in a MAIN source ends the compiler SESSION - the commands after COMPILE then go to
SINTRAN instead of to the compiler.
Detect: a $EOF in a file that contains a MODULE statement (i.e. is a main source rather
than an include). planc-lint.py implements this.
False positives: a file that is both - unusual. Warning.
Source: RM5 Appendix A 0.6, 0.8, 0.10 (p.250, p.252, p.254); UG6 p.181, p.189; CODE
planc-lint.py.
R90 - Compiler commands that must live outside the outermost module¶
$SEPARATE-DATA and $DEBUG-MODE must be used outside the outermost module level.
The compiler message is COMMAND NOT PERMITTED WITHIN A MODULE.
$COMPILE's source-file parameter must be separated from the command by at least one space.
Detect: \$(SEPARATE-DATA|DEBUG-MODE) between a MODULE and its matching ENDMODULE.
Source: RM5 Appendix G restrictions 10, 13 (p.345); RM5 error COMMAND NOT PERMITTED WITHIN A
MODULE.
R91 - Macro rules¶
$MACRO macname [(param[,param]...)]
body, referring to parameters as "param"
$ENDMACRO
$ENDIF is used as a parameter in a macro call it must be followed by at least one space.
Detect: $MACRO inside an unterminated $MACRO; a " in a macro body that is not a matched
parameter reference; a macro actual parameter containing a bare comma or ).
Source: RM5 Appendix A 0.11 (p.255-256), Appendix G restriction 19 (p.346); UG6 p.190, p.202-203.
Section 7 - Input and output¶
R92 - One data-element per call¶
"only one data-element may be input/output by a single input/output standard routine invocation."
INPUT (file-number, 'descriptor', identifier)
OUTPUT (file-number, 'descriptor', identifier)
Random unformatted forms take (file-number, record-number, bytes-array); the first record is
number 0.
Detect: four or more arguments to INPUT/OUTPUT where the second is a quoted descriptor.
Source: RM5 9 (p.224), 9.2 (p.226), 9.3 (p.233), 9.2.7, 9.3.8.
R93 - The format descriptors, and exactly what the width counts¶
| Descriptor | Meaning | Direction |
|---|---|---|
Iw |
decimal integer, field of w characters | in, out |
Ow |
octal integer, field of w | in, out |
Zw |
octal integer with leading zeroes | out only |
Fw.d |
fixed point real; w includes the decimal point and any minus sign; d decimals | in, out |
Ew.d |
normalised with exponent; w includes point, and the exponent is E, a sign and two digits |
in, out |
Dw.d |
as E, for double-precision reals | out only |
Aw |
alphanumeric, right-justified, leading blanks | in, out |
ALw |
alphanumeric, left-justified, trailing blanks | out only |
Lw |
Boolean, T/F in the rightmost position |
in, out |
w is an unsigned integer greater than zero. d is an unsigned integer >= 0.
Omitting w or w.d is legal: on output the compiler uses the minimum needed (UG6: default for
the target CPU) and you read the out-value to learn how many bytes went. On input it reads a
type-dependent default maximum.
Width counting - the rule that is actually violated:
- w is a FIELD WIDTH, not a maximum. Too small silently cuts the line off; too large pads it.
- On output of a numeric value that does not fit, the whole field is filled with asterisks (*).
- The count INCLUDES the trailing $ (the newline character), because $ is a character in the
string like any other. 'CHAT: bye$' is TEN characters.
- Nothing in the compiler checks the width against the literal. A wrong width builds clean and shows
up only on a terminal nobody may be watching.
Detect: for OUTPUT(dev, 'AL<n>', '<literal>') compute len(literal) with '' counted as one
character, and compare with n. Report both directions:
- n < len - the line is truncated (and for numeric formats, asterisks);
- n > len - the padding lands after the newline the $ emitted, so it indents the NEXT line.
A /help built from over-wide fields came out as a staircase (CODE).
planc-lint.py implements both. Regex:
"'AL(\d+)'\s*,\s*'((?:[^']|'')*)'".
False positives: a variable rather than a literal in the third argument cannot be checked.
Descriptors built at runtime cannot be checked. Leaving the width off entirely ('AL') is the
safer habit and must not be flagged.
Source: RM5 9.2 (p.227), 9.3 (p.233-239); UG6 p.196-197, p.214-215; CODE planc-lint.py,
PLANC-DEVELOPER-GUIDE.md.
R94 - $ inside an OUTPUT string is CR+LF¶
A single $ in a string being output becomes carriage return + line feed. To print a literal $,
write $$.
Detect: an odd-length run of $ in a literal handed to OUTPUT is intentional (a newline); a
$ in a literal handed to something that is not OUTPUT is probably a mistake - the character
is only special to Output.
False positives: SINTRAN command strings passed to MON70 contain no $ normally, but a file
name could. Warning only.
Source: RM5 9.3.6 (p.238); UG6 p.133, p.145.
R95 - Input field termination and parity¶
A field being read by a formatted INPUT terminates when the maximum w characters have been read,
or at a comma, or at a carriage return. Leading blanks count toward the field width but do
not affect the value.
For Lw, the field is scanned for the first T or F; anything else yields FALSE; and the
out-value is the character position (relative to 1) where the letter was found - not a byte
count.
THE PARITY TRAP (CODE, and the Monitor Calls manual warns of it): terminal input sets bit 7 as an even-parity bit, so a carriage return arrives as 141, not 13. Compare against 13 and the program never sees the user press return, and looks hung rather than wrong. Mask every byte read from a terminal with 127 before looking at it.
Detect: a comparison of a byte read from a device against a bare ASCII control value (13, 10,
27, 3) with no AND 127 on the same or a preceding line. Warning.
False positives: the mask may be applied further up the call chain. Warning only.
Source: RM5 9.2 (p.226), 9.2.6 (p.231); CODE CHAT.PLNC.
R96 - OPEN, CLOSE, BLOCKSIZE, FILESIZE¶
OPEN (file-number, file-access, file-name, file-type) - file-number is
INTEGER READ WRITE and is returned, not supplied. Default file type is SYMB.
Legal access codes: R, W, RW, WA, RX, WX, RC, WC, D, DC.
CLOSE (file-number).
int BLOCKSIZE (file-number) - blocksize in bytes, must be >= 1, file must already be open.
int FILESIZE (file-number) sets the size in bytes (int must be INTEGER4);
FILESIZE(file-number) =: int reads it.
Detect: an access code string handed to OPEN that is not in the list.
False positives: a variable access code.
Source: RM5 9.4-9.7 (p.240-242); UG6 p.213, p.164, p.184.
R97 - I/O errors arrive as ROUTINEERROR¶
"The ROUTINEERROR exception will be activated by errors in any of the input/output or
open/close standard routines. If a ROUTINEERROR condition occurs, the system variable ERRCODE
will contain a value from the file system."
Detect: an OPEN/INPUT/OUTPUT/CLOSE in a routine with no textually preceding
ON ROUTINEERROR handler (R99). Warning.
Source: RM5 9 (p.224).
Section 8 - Arrays, bounds and dynamic data¶
R98 - There is no array bounds checking¶
Off by default. $OPTION ARRAY-INDEX-CHECK ON turns on a compile-time or run-time check; the
initial value is OFF.
Two restrictions on the check itself: - If a subarray is used with bounds outside those declared for the original array, the compiler gives no warning, and after such a reference the run-time checking is carried out incorrectly. - RANGEERROR - the exception the check would raise - is not implemented (RM5 6.8; UG6 p.223).
Real cost, CODE: "PLANC does not check array bounds: without the test, an 81st character is
written past the end of this array and into whatever the compiler laid out next - which here is
roomName, typedLen, serverMagic and joined. A user holding a key down would quietly corrupt the
program's own state." And separately, a BYTES : roomName := 'CHAT-LOBBY' (ten bytes) accepting a
sixteen-character /join argument wrote past the end - a live defect, not tidying.
Detect:
1. Constant subscripts outside the declared bounds (R13) - always report.
2. A copy loop FOR i IN 1:n DO ... =: arr(i) ... where n comes from a parameter or an external
source and no clamp against the array's declared size appears in the routine. Warning.
3. A BYTES declared by an initialiser literal (so its length is the literal's length) that is
later written by a loop or a subarray store. Warning: give it an explicit range instead.
False positives: rule 2 is heuristic and will fire on safe code. Make it opt-in.
Source: RM5 4.1.5, 6.8 (p.171), Appendix A 0.14 (p.259), Appendix G restriction 22 (p.346);
UG6 p.162, p.223; CODE CHAT.PLNC.
R99 - Subarrays¶
array(lo:hi) and, for multi-dimensional arrays, omitting trailing dimensions
(twod(10) == twod(10:10, 1:100)).
- Each subarray index set must be a subset of the corresponding original index set.
- Subarray bounds may be variables -
name(0:len-1)is legal and is how you pass part of a buffer to a routine. Without one, a routine takingBYTESreceives the array's whole declared length, leftovers included (CODE). twod(10)(2)works buttwod(10,2)"gives much faster access at runtime".ADDR(subarray)builds a real array descriptor, storable in anARRAY POINTER.- Subarrays of fewer dimensions than the whole array cannot be READ/WRITE modified (UG6 p.223).
Detect: \w\)\s*\( - the slow two-step subscript form. Note, not error.
Source: RM5 4.1.6 (p.108); UG6 p.161; CODE.
R100 - The BYTES-ARRAY last-subscript exception¶
For a BYTES array of more than one dimension, the last subscript may be omitted and the
reference is then to the entire string:
BYTES ARRAY : b1(1:2,0:3) := ('abc','xyz')
BYTES : b2(1:3)
b1(1) =: b2 % stores 'abc'
Detect: nothing to flag; a linter counting subscripts (R23) must allow one fewer for a BYTES array or it will report every legitimate use. Source: RM5 4.1.3 (p.105), 4.2.3 (p.117).
R101 - PACKED arrays on the ND-100¶
- A PACKED array of two-byte integer subtypes must not have a negative lower bound in any index range (also: NEGATIVE BOUND ILLEGAL, ND-100 only).
- The address computation "demands that the declared lower index bounds must result in the first element of a packed integer array being placed on an odd byte". For more than one dimension: make the lower bound of the last dimension and the number of values in that index set a multiple of the number of elements per word.
- A pointer must not be used as a FOR control identifier over a PACKED array whose elements are smaller than the smallest addressable unit (R58).
ADDRused to write into a component of aRECORD PACKED/ARRAY PACKEDwhose size or alignment differs from an addressable element "may overwrite adjacent memory areas" - and sinceSTANDARD/REFERENCEparameters implicitly useADDR, the same trouble arises there.
Detect: a PACKED array declaration with a negative lower bound. A STANDARD/REFERENCE
routine taking a component of a packed structure.
Source: RM5 4.2.5 (p.120), Appendix G restrictions 21, 22 (p.346); UG6 p.161, p.216;
ADV 4.3 (p.68).
R102 - NEW and DISPOSE¶
NEW data-type [IN int-array] =: pointer
NEW (array-type (index-set[,index-set]...)) [IN int-array] =: pointer
DISPOSE pointer
NEW data-typemay not be an array type - arrays use the second form with explicit index sets.- Without
IN, the object goes on the stack and is lost when the routine exits. - Before the first
NEWinto a given array, the storage-management area must be initialised:0 =: arr(MININDEX(arr,1)). This is easy to forget and the manual states it as a Note. - Per-array overhead: free area pointer 1 word, maximum area pointer 1 word, storage management area 15 words; plus 2 extra words per NEW.
DISPOSEd space is reused only by a request of exactly the same size. There is no garbage collection or compaction.NEWraisesPOINTERERRORif the target area is too small - but see R106 for the caveats.
Detect: a NEW ... IN arr where no 0 =: arr(MININDEX(arr,1)) (or equivalent) appears earlier
in the file. A NEW with no IN whose pointer is stored into a module-level variable - the object
dies at routine exit.
False positives: the initialisation may be written differently (0 =: arr(0)). Accept any store
of 0 into element MININDEX of that array.
Source: RM5 4.5 (p.129-131), 7.9 NEW (p.201); UG6 p.197, p.209-210.
R103 - INSERT, APPEND, REMOVE on linked lists report nothing when they fail¶
rec-pointer INSERT listhead:linkfield
rec-pointer APPEND listhead:linkfield
rec-pointer REMOVE listhead:linkfield
RM5 4.6: these "will not give any error indication if the record pointer in the routine invocation is empty (i.e. has value NIL). This also applies to REMOVE if the linked list is empty. Take care to remember that if INSERT or APPEND is used on a record that is already in a linked list, there is no error indication, but the address link field will be overwritten."
APPEND requires the list's last link pointer to be NIL.
UG6 p.216 says Remove raises POINTERERROR when the element is not in the list - which
contradicts RM5's "no error indication". Version difference or a manual error; treat as
UNVERIFIED and do not rely on either.
Detect: an INSERT/APPEND whose in-value pointer is not tested against NIL on any path
reaching it. Warning at most.
Source: RM5 4.6 (p.132-134); UG6 p.148, p.185, p.216.
Section 9 - Exception and error handling¶
R104 - The handler shape and where it must sit¶
ON exception[,exception]... DO
stmts
ENDON
- Exception conditions:
ASSERTFALSE,OVERFLOW,POINTERERROR,RANGEERROR,ROUTINEERROR,STACKERROR. Nothing else is a legal name here. - A handler is only in effect for source code that FOLLOWS it, within a routine.
- If several handlers for one condition appear in a routine, the one immediately preceding the occurrence in the source is the one that runs.
ASSERTFALSE,OVERFLOWandPOINTERERRORare handled only in the routine where the condition is raised.ROUTINEERRORandSTACKERRORare handled in the raising routine or anywhere up its call hierarchy.ENDONbehaves like aRETURNof the failed call: control resumes at the statement after the one that raised the condition (or after the failed routine call).
Detect: an ON naming a condition not in the list. An ON ... DO with no matching ENDON
(MISSING KEYWORD ... ENDON). A call that can raise (any monitor call, any I/O call, any routine
using ERRETURN) with no handler textually above it in the same routine.
False positives: a handler declared in a caller covers ROUTINEERROR - so only flag the
per-routine conditions strictly.
Source: RM5 6.8 (p.170-171); UG6 p.199, p.211-212.
R105 - Which exceptions actually work¶
| Condition | Status per RM5 6.8 (version G) |
|---|---|
ASSERTFALSE |
works |
OVERFLOW |
works, but only hardware raises it; and ON OVERFLOW "does not detect overflow conditions for unsigned integer data-elements" |
ROUTINEERROR |
works |
POINTERERROR |
not implemented for the documented case (a NIL dereference). It IS raised by NEW ... IN when the space is inadequate, and "only on overflow from NEW in a special area" |
RANGEERROR |
not implemented |
STACKERROR |
not implemented. And: "Stack overflow from recursive routine calls will never invoke exception blocks." |
RM5 6.8 also records: "ROUTINEERROR blocks will be invoked from NEW, but will receive error code -1 if the allocation was in the stack, and 0 if the allocation was in another area."
The system routine that handles stack overflow can be replaced by a user routine with its own
stack, whose ALIAS name must be 5STCO.
Detect: an ON RANGEERROR or ON STACKERROR - report that it will never fire (UG6 says the
compiler warns for RANGEERROR). An ON POINTERERROR used to guard a NIL dereference - report that
it does not catch that.
Source: RM5 6.8 (p.171); RM5 Appendix G restriction 6 (p.344); UG6 p.211-212, p.223, p.224.
R106 - ERRETURN, ERRCODE and the fall-through¶
expression ERRETURN where the expression is INTEGER. The value lands in the system variable
ERRCODE.
If the caller has an ON ROUTINEERROR textually before the call, control goes there. Otherwise
control goes to the next higher level, and so on, until a handler is found or the outer level is
reached, at which point the program terminates with the runtime message
NO ON ROUTINEERROR HANDLER, ERRETURN.
"If a ROUTINEERROR exception occurs and no exception handler has been provided, an ERRETURN exit from the routine will be simulated" - i.e. the current routine also fails.
After a handler runs, control goes on in one of three ways: ENDON (acts as if the failed call had
RETURNed), a GO to a label, or a RETURN/ERRETURN from the routine containing the handler.
Detect: ERRETURN inside a ROUTINE STANDARD ... - not available (R75). ERRETURN with a
non-integer expression. Reading ERRCODE without an ON ROUTINEERROR above it.
Source: RM5 7.5 (p.193-194), 6.8 (p.171), Appendix B runtime messages (p.272); UG6 p.170,
p.182, p.220.
R107 - Do not print from inside a handler that wraps a loop¶
Measured, CODE (CHATSV.PLNC): "ONE FLAG FOR THE WHOLE LINE, not a message per byte. CHAT.PLNC
learned that the hard way: a handler round a write loop that prints inside it says the same thing
once per character." Because ENDON resumes at the statement after the failing one, a handler
wrapping a loop runs on every iteration.
Right shape:
ON ROUTINEERROR DO
TRUE =: writeFailed
ENDON
FALSE =: writeFailed
FOR i IN 1:n DO
... the call that can fail ...
ENDFOR
IF writeFailed THEN ... ENDIF
Detect: an ON ... DO block containing an OUTPUT call, where the guarded region (the code
between ENDON and the end of the routine) contains a loop. Warning.
Source: CODE CHATSV.PLNC, CHAT.PLNC.
Section 10 - Rules that came from this machine, not from a manual¶
These have all cost a real build cycle on D100. They are measured, and several contradict or
extend what the manuals say. planc-lint.py already implements most of them.
R108 - A string literal cannot be stored into an element of a BYTES array¶
Wrong: ' ' =: buf(i)
'x' is a BYTES; buf(i) is a BYTE.
The compiler answers:
1082 (422)/BUILDWHO *** ERROR - ILLEGAL DATA TYPE "OUTBUF"
Right: hold the character in a one-element BYTES and copy element to element. The same idiom
is needed in the other direction to PRINT one byte, since 'ALn' formats a string and handing
it a byte prints the byte's NUMBER. (In CHAT.PLNC that turned RONNY into 8279787889 on
screen.)
Detect: '(?:[^']|'')*'\s*=:\s*([A-Za-z_]\w*)\s*\(.
False positives: storing a string into a subarray - 'abc' =: magic(10:12) - is legal and
correct. Distinguish by whether the subscript contains a :.
Source: CODE planc-lint.py, CHAT.PLNC.
R109 - #c (a BYTE) versus 'c' (a BYTES) - the same rule from the other side¶
#a is a BYTE. 'a' is a one-element BYTES. RM5 2.10 states it flatly: "Note that 'a' is not
equivalent to #a and has a different internal representation."
Detect: a #c literal stored into a declared BYTES variable, or a 'c' literal stored into a
declared BYTE variable.
Source: RM5 2.9, 2.10 (p.76); CODE.
R110 - Read the LISTING, because errors do not survive on screen¶
Not a source rule, but the reason the linter exists, and worth emitting as advice in any report:
The compiler prints diagnostics as it goes. On a long source they scroll off a 24-line terminal.
The 0 DIAGNOSTICS left on the screen at the end belongs to the BRF-LINKER, and sits happily
under a compile that failed. BRF-LINKER writes a runnable :PROG with entries still undefined;
@CHAT then starts it and it behaves oddly in a way that looks like evidence about something else.
Two consequences for a build script:
- capture and read <name>:LIST;
- run LIST-ENTRIES-UNDEFINED after linking and read its output - nothing else fails.
Source: CODE CHAT.PLNC, planc-lint.py.
R111 - A local filespec in a SINTRAN command string must NOT be quoted¶
The two routes to "make me a file" take opposite quoting:
| Route | Quoting |
|---|---|
MON50(file, ...) |
quoting the name means CREATE IT IF ABSENT |
'CREATE-FILE <name>' via MON70 |
quoting the name is ILLEGAL CHARACTER IN PARAMETER |
so a quoted name copied from the first into the second fails every time. Measured on D100:
'CREATE-FILE "CHAT:CNFG",1' answered ILLEGAL CHARACTER IN PARAMETER, the file was never created,
and every nickname save had been failing silently.
Quoting IS right for a remote filespec, which goes to another machine's command processor.
Detect: inside a string literal, (CREATE-FILE|DELETE-FILE|RENAME-FILE)\s+" where the name has
no (system) prefix.
False positives: remote filespecs - hence the prefix test.
Source: CODE planc-lint.py.
R112 - Source files must have CRLF line endings and EVEN PARITY¶
A SINTRAN text file needs bit 7 set as an even-parity bit. Without it LIST-FILE shows nothing,
silently, and QED reports PARITY ERROR.
But do not "fix" a log file for this reason. CHATSV.PLNC records a full control run: the same
two commands against a file that is unquestionably good answer exactly the same way. Both tools
behave that way toward every file the project's FA push puts on the machine. The parity theory was
tested and found to be a wrong conclusion from a missing control.
Detect: file-level check on line endings. Report parity as informational only.
Source: PLANC-DEVELOPER-GUIDE.md; CODE CHATSV.PLNC (which retracts the parity conclusion).
R114 - Two EXPORTs that agree in their first SEVEN characters are ONE name¶
Names are unique in ten characters to the compiler but only seven across an
EXPORT/IMPORT, because seven is what a BRF entry carries (ND-60.117.5 appendix G item 27).
What makes this dangerous is what the linker does NOT do. It does not report a duplicate. It
resolves every matching import to whichever entry it met first, so calls meant for one routine
land in the other - and if the two have different signatures, the callee reads arguments that
were never passed. The compile is clean, the link is clean, and LIST-ENTRIES-UNDEFINED is
empty, because nothing is undefined. There is no message anywhere.
EXPORT cmTxLen % CMTXLEN
EXPORT cmTxLenN % CMTXLEN <- the same name to the linker
The compiler only says IDENTIFIER ALREADY SPECIFIED/DECLARED when the collision is inside ONE
module at ten characters. Across modules at seven, it says nothing at all.
Detect: compare the first seven characters, upper-cased, of every EXPORT in a module and
refuse any pair that matches. Do the same across modules that are linked together.
Source: ND-60.117.5 appendix G item 27; MEASURED 2026-08-27 - caught by hand while adding a
routine beside one it collided with, which is why it is a linter check now.
R115 - MAXINDEX works on an array PARAMETER, and on a SUBARRAY¶
A routine can ask an array it was PASSED how big it is. MEASURED on D100 2026-08-27, all three:
ROUTINE VOID, INTEGER (BYTES) : askSize(a)
MAXINDEX(a, 1) RETURN
ENDROUTINE
askSize(buf) -> 255 % BYTES : buf(0:255) - a whole array parameter
askSize(nm) -> 15 % BYTES : nm(0:15) - it sees the SMALLER one
askSize(tx(0:9)) -> 9 % a SUBARRAY, bounds and all
Why it matters: it removes a whole class of caller-trust bug. A routine told a size by its
caller - writeInto(buf, ..., bufMax) - has no way to check that number, and PLANC checks no
array bound, so a caller that passes the wrong one gets a silent overflow. MAXINDEX(buf, 1) + 1
is the real size and cannot be lied about.
A BYTES parameter subscripts from zero, so the usable length is MAXINDEX(a, 1) + 1.
The one restriction (ND-60.117.5 p.249): MININDEX/MAXINDEX on array parameters are NOT
available in a STANDARD routine - the FORTRAN/COBOL calling sequence. An ordinary PLANC routine
is fine.
Detect: partly a lint rule now - see R121, which refuses a hand-counted length beside a literal. The rest is a design rule: prefer asking the array over believing a size. Source: ND-60.117.5 3.17 p.52, p.153, p.249; MEASURED on D100 2026-08-27.
R116 - A clamp that bounds only ONE field is not a clamp¶
When several fields are written into one buffer, every one of them has to be bounded, not just the last. The usual shape of the bug is a clamp that reduces the FINAL field to make things fit while an earlier field has already been allowed to fill the buffer on its own:
IF headerLen + bodyLen > bufMax THEN
bufMax - headerLen =: bodyLen % only the BODY is reduced
IF bodyLen < 0 THEN 0 =: bodyLen ENDIF
ENDIF
...
FOR i IN 1:headerLen DO ... ENDFOR % still writes headerLen bytes
With headerLen larger than bufMax, the body clamps to nothing and the header writes past the
end regardless. Nothing reports it - see R24, PLANC checks no array bound - and the overflow is
usually a few bytes, which corrupts whatever the compiler happened to place next.
Clamp each field against what is left after the ones before it, and treat a comment that claims a routine is bounded as a claim to be checked, not a fact.
Detect: for each field written into a buffer, is there a bound derived from the buffer size? Source: MEASURED 2026-08-27 - a name field written into a 256-byte buffer put three bytes past the end while the text field was correctly clamped to nothing.
R117 - A test that overflows an array can PASS¶
This is the testing consequence of R24, and it is worth its own rule because it makes a test suite actively misleading.
BYTES : buf(0:255)
BYTES : src(0:63)
writeInto(buf, src, 300, 512) % 512 is a LIE - buf holds 256
check('length high byte', buf(7), 1) % PASSES - byte 7 is in bounds
check('length low byte', buf(8), 44) % PASSES
Fifty-three bytes went past the end of buf and 236 past the end of src, and every assertion
passed, because the assertions all landed on bytes that were in bounds. The suite printed its
success line.
So assert the TOTAL, not only the fields. A message can never be longer than the buffer it was written into, and that single check is the one that catches it:
check('total fits the buffer', len, expectedTotal)
And size the fixtures for the case being tested rather than telling a routine the buffer is bigger than it is.
Detect: any call whose declared maximum exceeds the declared size of the array passed with it. Comparable literals make this checkable. Source: MEASURED 2026-08-27 in a live test suite that reported success while corrupting memory on every run.
R118 - A BOOLEAN will not pass where an INTEGER is declared¶
PLANC does not convert between them at a call. A routine declared
ROUTINE VOID, VOID (BYTES, INTEGER, INTEGER) : check(...) cannot be handed the result of a
BOOLEAN function, and an expression like len <= 256 is a BOOLEAN, not a 0 or a 1.
Write a second routine for the BOOLEAN case rather than trying to convert. It also produces
better output - got TRUE want FALSE says more than got 1 want 0.
Related: R108 and R109 are the same idea for BYTES and BYTE, and both report the type error
against the wrong identifier, so check the ARGUMENT TYPES before the argument the message names.
Detect: a BOOLEAN-valued expression or routine passed where the declaration says INTEGER. Source: MEASURED 2026-08-27.
R119 - Every IMPORT and EXPORT belongs in one block, before any ordinary declaration¶
An IMPORT or an EXPORT that appears after an ordinary declaration at module level draws
991 (331) *** ERROR - MISPLACED STATEMENT "IMPORT"
131 *** ERROR - MISPLACED STATEMENT "EXPORT"
one per offending line. The message is clear for once, but the shape of the mistake is easy to
make: replacing a constant declaration with an IMPORT where it stood is the obvious edit
when a constant moves into a shared library, and it reads perfectly well - each IMPORT sitting
under the comment that explains that constant.
MEASURED 2026-08-27: ten constants moved to a library, ten IMPORTs written in their old
places, ten errors in one compile - four minutes to be told something a linter answers instantly.
The comment can stay where it is. Only the IMPORT has to move to the block at the top; a
one-line note where the constant used to be keeps the explanation next to the thing it explains.
The same rule caught EXPORT the same day, and that one is worse¶
Ten trunk constants were exported from CHATLIB beside their own declarations - below the main
EXPORT block, under the comment explaining them - and every one drew
MISPLACED STATEMENT "EXPORT".
What the machine did with those ten errors is the part worth remembering:
| step | what it said |
|---|---|
LIST-ENTRIES-UNDEFINED |
nothing - no undefined entries |
| the loader | linked and produced a program |
| the program | ran |
| its own test suite | 139 checks, 0 failures |
| the last line on screen | codec is good |
A compile with ten errors produced a program that passed its own tests, including checks that read those very constants back with the right values. Only the listing said anything was wrong. That is the whole reason this project gates on the pulled listing rather than on the screen - and the reason a check costing a second belongs in front of a compile costing minutes.
Detect: any module-level IMPORT or EXPORT whose line number is greater than that of the
first module-level INTEGER/BYTES/BOOLEAN/REAL declaration.
Source: MEASURED on D100 2026-08-27, both halves; tools/planc-lint.py checks it, and
tools/fixtures/LINT-BADCASE.PLNC pins it.
Appendix A - Compiler messages worth mapping to rules¶
When a linter reports something, quoting the message the compiler will actually print saves a build cycle. The mapping:
R120 - A name may be IMPORTed ONCE. The second one is an ERROR.¶
Importing a name that is already imported answers
*** ERROR - ILLEGAL PREDECLARATION
and that is an ERROR, not the warning a repeated ordinary declaration usually gives - so the compile fails rather than shrugging.
This is the one a MERGE produces and nobody notices. Two halves of a source both legitimately
needed MON1, and each said so in its own IMPORT block. Bringing the halves together is a clean
edit that reads correctly in the diff, and the duplicate only exists in the combined file.
It is the same family as the ? predeclaration rule - predeclaring twice is also
ILLEGAL PREDECLARATION - because an IMPORT is a declaration of a name defined elsewhere, and
the compiler will accept exactly one of them.
MEASURED 2026-08-28: kHist was imported into CHATTST beside six new routine imports, while it
had already been imported forty lines further down. tools/planc-lint.py refused it on Windows in
under a second; the compile it saved is four minutes on the machine.
Detect: collect every name in an IMPORT at module level and report any that appears twice.
Compare case-insensitively - PLANC does not distinguish case in identifiers.
False positives: none seen. Two imports of one name have no legitimate use.
Source: RM5 3.16 p.98 (predeclaration); CODE planc-lint.py, MEASURED on D100 2026-08-28.
R121 - A helper must NEVER take (text, textLen). Derive the length.¶
The moment a routine accepts a length beside the string, every call site has to type a number that nothing can check - not the compiler, not the linker, not a test. It is correct on the day it is written and wrong the first time somebody edits the wording, and it still builds clean.
% WRONG - the caller counts, and the count rots
ROUTINE VOID, INTEGER (BYTES, INTEGER, INTEGER) : putWord(text, textLen, at)
...
putWord('trunk added', 11, at) =: at
% RIGHT - the string knows how long it is, so ask it
ROUTINE VOID, INTEGER (BYTES, INTEGER) : putWord(text, at)
INTEGER : i, p, textLen
MAXINDEX(text, 1) + 1 =: textLen
...
putWord('trunk added', at) =: at
MEASURED 2026-08-31 on the chat product: 93 hand-typed numbers deleted in one sitting - 29
putWord, 19 logLine, 18 buildAdmText, 15 tryCmd, 14 cmdIs, 12 showIfMatch. All 93 were
CORRECT at the time, so this removed a whole class rather than fixing a live fault. Audit before
refactoring - a dozen lines of Python comparing each number with its literal - because knowing
whether you are fixing a hazard or a bug changes how it is reported.
Two preconditions, both cheap to check:
- Every call site passes a LITERAL.
MAXINDEXgives the DECLARED bound, so a caller handing a 64-byte buffer that holds a 10-byte name would get 64. Where the text is a buffer, the length is real information and must still be passed. Grep the call sites for a first argument that does not begin with a quote - and beware that the ROUTINE definition line matches that grep too. - The routine is not
STANDARD-MAXINDEXon a parameter is unavailable in the FORTRAN/COBOL calling sequence (R115).
A BYTES DECLARED FROM A LITERAL BEHAVES THE SAME WAY - MEASURED, not assumed. R115 measured
MAXINDEX on array parameters and on a subarray, but not on BYTES : v := 'DROP-MEMBER' passed as
a parameter, which is the shape tryCmd and showIfMatch in CHAT-MON now rely on. Three checks
were added to the on-machine suite rather than trusting that it "should" work, because the symptom
of being wrong is a menu where a command quietly stops matching - which reads as a logic bug, not
an array question. MEASURED on D100 2026-08-31, all three PASS:
BYTES : litShort := 'HELP' % MAXINDEX + 1 -> 4
BYTES : litVerb := 'DROP-MEMBER' % MAXINDEX + 1 -> 11
BYTES : litLong := 'RESTART-TRUNK' % MAXINDEX + 1 -> 13
So the bounds of a literal-initialised BYTES are 0:len-1, exactly like a literal at a call
site.
Why not a length constant declared next to the text? It puts the two facts closer together but
leaves them as two facts, and the failure mode is unchanged: edit the text, forget the number,
builds clean. Why not a macro? PLANC's compile-time macros (RM5 0.11 p.208) are text
substitution with parameters; there is no compile-time string-length operator to substitute, so a
macro can wrap the call but has nothing to put in the length slot. Name the text, never the
length - a named BYTES : vDropMbr := 'DROP-MEMBER' is worth having for reuse and readability,
and the helper still derives its length from it.
One trap, three costumes. An 'ALn' field width (R28), a hand-counted length beside a literal
(this rule), and a start column written as 65 - length are the same defect: a number a human
counted, that nothing verifies, that builds clean when wrong. Meeting one is a reason to look for
the other two.
Detect: two NAMED lists in planc-lint.py. LITERAL_LENGTH_HELPERS holds helpers that still
take a length - the number must equal the literal - and is deliberately EMPTY, because that is the
goal state rather than an oversight. DERIVES_ITS_OWN_LENGTH names every helper whose parameter
has been removed, so the old shape is refused by name if it comes back; the compiler's own
complaint about the parameter list never mentions MAXINDEX or why the parameter went away.
False positives: none, provided a helper that legitimately takes a buffer length stays out of
both lists.
Source: RM5 3.17 p.52, p.153, p.249 (MAXINDEX), 0.11 p.208 (macros); CODE planc-lint.py,
SINTRAN/XMSG/SINTRAN-CHAT/, MEASURED 2026-08-31.
| Compiler message | Rule |
|---|---|
| ARRAY BOUNDS MISSING | R24 |
| ARRAY BOUNDS CONFLICT WITH A PREDECLARATION | R36 |
| COMMAND NOT PERMITTED WITHIN A MODULE | R90 |
| CONFLICTING DATA TYPES IN CORRESPONDING IMPORT/EXPORT | R84 |
| DATA TYPE NOT PREVIOUSLY SPECIFIED | R74 |
| EQUIVALENCE MAY CAUSE STORAGE CONFLICT | R37 |
| EXITFOR / EXITWHILE ALREADY PRESENT WITHIN THE LOOP | R58 |
| EXPRESSION DOES NOT STORE A VALUE | R19 |
| IDENTIFIER ALREADY SPECIFIED/DECLARED | R4, R39, R113 |
| (no message at all) - two EXPORTs equal in 7 chars | R114 |
| (no message at all) - a write past the end of an array | R24, R116, R117 |
| (no message at all) - a length typed beside a literal | R28, R121 |
| ILLEGAL PREDECLARATION | R36, R120 |
| MISPLACED STATEMENT "IMPORT" | R119 |
| MISPLACED STATEMENT "EXPORT" | R119 |
| IDENTIFIER IN EXPORT, BUT NO DECLARATION | R83 |
| ILLEGAL CHARACTER | R1, R2, R15 |
| ILLEGAL CONTROL IDENTIFIER | R58 |
| ILLEGAL DATA-ELEMENT TO BE CONVERTED (a WARNING - the build survives) | R46 |
| ILLEGAL DATA TYPE | R108, R109 |
| ILLEGAL FORMAL PARAMETER IN MACRO | R91 |
| ILLEGAL INLINE INVOCATION | R75 |
| ILLEGAL MODULE TERMINATION | R83 |
| ILLEGAL PARAMETER REFERENCE IN MACRO BODY | R91 |
| ILLEGAL PREDECLARATION | R36 |
| ILLEGAL SYNTAX | R19, R41, R45, R73, R74 |
| INCASE CONTAINS INVALID VALUE | R61 |
| INCOMPATIBLE DATA TYPES | R33 |
| INCONSISTENT DIMENSIONS | R23 |
| INISTACK INVOCATION MISSING | R86 |
| INITIALIZATION VALUES OVERFLOW DECLARED SIZE | R29 |
| INVALID ACTUAL PARAMETER, FORMAL PARAMETER DECLARED AS WRITE | R67 |
| INVALID ARRAY FOR INISTACK INVOCATION | R86 |
| INVALID PARAMETER LIST | R41, R65 |
| INVALID TYPE FOR IN-VALUE/OUT-VALUE/PARAMETER | R77 |
| MISSING KEYWORD, ENDIF/ENDCASE/ENDFOR/ENDDO OR ENDON | R55, R58, R61, R104 |
| MORE SUBSCRIPTS THAN IN THE ARRAY DECLARATION | R4, R23 |
| MULTIDIMENSIONAL ARRAY NOT ALLOWED HERE | R58, R86 |
| NEGATIVE BOUND ILLEGAL (ND-100) | R101 |
| NOT PREVIOUSLY DECLARED | R40, R74, R88 |
| QUALIFIER REQUIRED FOR THIS RECORD COMPONENT | R52 |
| REQUIRE ELSE OR ALL POSSIBLE VALUES USED IN INCASE PARTS | R61 |
| ROUTINE WITH AN OUT-VALUE REQUIRES A RETURN | R69 |
| SET MEMBER OVERLAP | R31 |
| SQUEEZE OPTION GENERATES INCORRECT CODE FOR THIS ROUTINE (ND-100) | note only |
| WRITE DECLARATION ILLEGAL IN READ ONLY RECORD | R27 |
| ASSERT VIOLATION AT address (runtime) | R63, R39 |
| STACK OVERFLOW AT address (runtime) | R76, R86 |
| NO ON ROUTINEERROR HANDLER, ERRETURN (runtime) | R106 |
Source: RM5 Appendix B (p.266-272).
Appendix B - Things the manuals leave genuinely unclear¶
Marked so nobody builds a rule on them.
- UNVERIFIED - the special-character set for routine names. RM5 p.176 prints a dagger-like
glyph among
! " $ * + - . / : < = > ? \ [ ]. The OCR cannot be trusted here and no other source repeats the list. - CONFLICT - SHIFT fill on right shifts. RM5 says sign-extend for signed types; UG6 says zeros, full stop. See R48.
- CONFLICT - trailing/double underscore in identifiers. RM5 forbids; UG6 and ADV allow. See R3.
- CONFLICT -
Removeon a missing list element. RM5 says no error indication at all; UG6 saysPOINTERERRORis raised. See R103. - UNVERIFIED -
<>as a not-equal operator. Appears once in UG6's SET entry; contradicted everywhere else. See R45. - UNVERIFIED - reading a routine LOCAL after its ON block has run. The manual documents the unpredictability of out-values and WRITE parameters after a handler; the project observed the same for locals and works around it with module-level variables. See R71.
- CONFLICT - whether an undeclared name is diagnosed. RM5 lists NOT PREVIOUSLY DECLARED; the
project measured
0 DIAGNOSTICSfor a store into a name declared nowhere, on PLANC F. See R40. - UNVERIFIED - which spelling of INISTACK the OCR intends in any given example. Four spellings
appear across the two manuals.
INISTACKis the one in the keyword tables and the one that compiles. See R86. - NOT COVERED HERE - PLANC-MC (MC68000) code generation, ND-500 packing layout, storage sizes. All of that lives in RM5 Appendix C and is machine-dependent; a linter should not encode sizes.
Appendix C - Suggested severity for a linter¶
| Severity | Rules |
|---|---|
| Error (will not build, or builds and is certainly wrong) | R1, R2, R14, R15, R19, R21, R23, R24, R26, R28, R32, R41, R45, R55, R56, R61, R65, R69, R73, R77, R80, R84, R85, R86, R108 |
| Error, silent at compile time (this is where the days go) | R4, R11, R13, R31, R36, R39, R40, R54, R74, R88, R89, R93, R98, R111, R113 |
| Warning | R3, R5, R8, R9, R20, R29, R34, R35, R37, R38, R51, R53, R57, R58, R59, R60, R62, R66, R67, R68, R71, R75, R76, R78, R79, R81, R82, R90, R91, R95, R96, R97, R102, R103, R104, R105, R106, R107 |
| Note / informational | R6, R7, R10, R12, R16, R17, R18, R22, R25, R27, R30, R33, R42, R43, R44, R46, R47, R48, R49, R50, R52, R63, R64, R70, R72, R92, R94, R99, R100, R101, R109, R110, R112 |
Rule count: 114.
Last updated: 2026-08-31
Written for: a linter author extending E:\Dev\Ronny\NDInsight\SINTRAN\XMSG\tools\planc-lint.py