From d482596b9045ecaaedd64e28c237f7f2ca2fd06d Mon Sep 17 00:00:00 2001 From: logan Date: Fri, 14 Aug 2026 17:04:53 -0400 Subject: [PATCH 1/3] feat: a matrix disposes of one file, and a skill ships none it cannot grade (#99) `ground --check` disposed of every unit in `SKILL.md` and opened no other file. Four of the six install pathways copy a skill directory whole, so the two files under `skills/standards/simplified-technical-english/references/` reached a user with no row disposing of a line in either. One of them maps real `Rule N.N` identifiers to topic labels, which are claims about the source that no `G` row answered for. ADR-0025 settled that those files are graded rather than evicted and could not do the work, so it printed a count instead. A matrix disposes of ONE file now. `SKILL.md` keeps `grounding//.md`, and every Markdown file under `references/` answers to a matrix that mirrors its own path. The row space is what forces that: `Our anchor` names a heading, two files in one skill can carry the same heading, and a shared space let a row claim an occurrence in the file nobody wrote it for while every cell still matched. The file identity sits in the matrix's path, where a filesystem holds it rather than a cell, so no column moved and no digest changed. A graded file with no matrix is an error naming the matrix to write, and so is a matrix that grades no file. A file under `references/` that is not Markdown is refused, because the walk reads Markdown alone and nothing can grade a file it cannot read. Every finding carries the file it came from, and the command prints it. ADR-0030 records the decision, and it amends ADR-0025's count from a note to an error. A blockquote is a unit the checker reads rather than a construct it refuses. `examples.md` is written in blockquotes, and the walk refused 41 of its lines. The refusal was never about the marker: the walk merged the quote with its contents, so a quoted list reached a row as a paragraph carrying its own markers. The quote is one block now, named by a digest of what it holds, which is the disposition a table and a fenced block already have. A reading a reader agrees with removes the exception, and that is ADR-0016 applied forwards rather than a rule naming a shape. A line directly under a quote is refused instead. A reader continues a quote over a line that carries prose and ends it at one that interrupts a paragraph, and which of those depends on the block open inside the quote, which this walk holds no state for. The remedy is the blank line every shipped file already has, and `test/gfm-render.test.js` pins the over-refusal beside the render that measures it. ADR-0031. `examples.md` went from 113 units and 41 refusals to 113 units and none. The two files carry 124 rows between them, six of them `G` rows, and every one reads `unquoted` and `unaudited`. Both matrices forbid quotation in the words the skill's own matrix uses. Nobody read the standard on this date, and the source record says so. Closes #99 --- AGENTS.md | 65 +++-- CHANGELOG.md | 28 +++ CONTRIBUTING.md | 36 ++- README.md | 27 +- ...-directory-ships-what-something-governs.md | 13 +- .../adr/0030-a-matrix-disposes-of-one-file.md | 121 +++++++++ docs/adr/0031-a-blockquote-is-a-block.md | 95 +++++++ docs/specs/2026-07-26-stylewright-design.md | 4 +- .../references/examples.md | 159 ++++++++++++ .../references/rule-navigation.md | 55 +++++ .../standards/simplified-technical-english.md | 8 + src/catalog.js | 36 +++ src/cli.js | 6 +- src/ground.js | 233 ++++++++++++++---- test/catalog.test.js | 28 ++- test/gfm-render.test.js | 98 +++++++- test/ground.test.js | 186 +++++++++++++- 17 files changed, 1099 insertions(+), 99 deletions(-) create mode 100644 docs/adr/0030-a-matrix-disposes-of-one-file.md create mode 100644 docs/adr/0031-a-blockquote-is-a-block.md create mode 100644 grounding/standards/simplified-technical-english/references/examples.md create mode 100644 grounding/standards/simplified-technical-english/references/rule-navigation.md diff --git a/AGENTS.md b/AGENTS.md index 73dab71..cf70f15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,19 @@ them, so look for them first. Every unit of content in a graded section of a skill is disposed of in `grounding//.md`. Nothing enters a skill unclassified. +A matrix disposes of ONE file. `SKILL.md` answers to +`grounding//.md`, and every Markdown file under `references/` +answers to a matrix mirroring its path, such as +`grounding/standards/simplified-technical-english/references/examples.md`. The +row space is what forces that. `Our anchor` names a heading, two files in one +skill can carry the same heading, and a shared space let a row claim an +occurrence in the file nobody wrote it for while every cell still matched. The +file identity sits in the matrix's own path, where a filesystem holds it rather +than a cell, so no column moved and no digest changed. A graded file with no +matrix is refused, and so is a matrix that grades no file. `references/` holds +Markdown, because the walk reads Markdown alone and nothing can grade a file it +cannot read. ADR-0030 records the decision. + - A **`G` row** claims the authority of the source. Its rule cell names the rule. - An **`E` row** is our own editorial guidance. Its rule cell is empty. - An **`N` row** is narrative. It orients the reader and asserts no rule, so it @@ -51,12 +64,12 @@ entered the STE skill unclassified while `ground --check` reported clean. Any change that narrows what the checker sees reopens that hole, whatever it widens elsewhere. -A table and a fenced block are units. Neither fits in a matrix cell, so each -carries a designator such as `[table 8f3a2b1c]`, whose digest names the block -CONTENTS. An ordinal named a position instead, so a table could be rewritten -whole while the matrix stayed clean. Exempting these was the first attempt at -this fix, and it was the same defect renamed. A rule written as a table is -still a rule. +A table, a fenced block and a blockquote are units. None of them fits in a +matrix cell, so each carries a designator such as `[table 8f3a2b1c]`, whose +digest names the block CONTENTS. An ordinal named a position instead, so a table +could be rewritten whole while the matrix stayed clean. Exempting these was the +first attempt at this fix, and it was the same defect renamed. A rule written as +a table is still a rule. There are no exempt headings and no exempt sections. A heading is a unit, so is anything above the first heading, and `Source`, `Boundary` and `Notice` grade @@ -254,9 +267,23 @@ The checker reads Markdown a line at a time, and it models no container. So it states the forms it reads and refuses every line outside them. Those forms are a blank line, any construct at column 0, a line that continues the paragraph above it while carrying prose, and an indented code block that stands on its -own. A blockquote, an empty marker and an empty heading are the exceptions at -column 0, because the checker does not read those either. Anything outside the -forms fails as `unmodelled-construct`, with the line and what to write instead. +own. An empty marker and an empty heading are the exceptions at column 0, +because the checker does not read those either. Anything outside the forms +fails as `unmodelled-construct`, with the line and what to write instead. + +A blockquote was a third exception until the walk was given a reading of one. +It is a block now, from its first marker at column 0 to the first line without +one, named by a digest of what it holds. The refusal was never about the +marker: the walk merged the quote with its contents, so `> - one gasket` +reached a row as a paragraph carrying its own markers. A reading a reader agrees +with removes the exception, and that is ADR-0016 applied forwards rather than +a rule naming the shape. A line directly under a quote is refused instead, +because a reader continues the quote over a line that carries prose and ends it +at one that interrupts a paragraph, and which of those depends on the block open +INSIDE the quote. The remedy is the blank line every shipped file already has. +An indented marker stays refused, as an indented table does. ADR-0031 records +the decision, and `test/gfm-render.test.js` pins the over-refusal beside the +render that measures it. A continuation line states what it may BEGIN with, and that is the third form read the same way round as the rest. It carries a letter, a digit or ordinary @@ -357,9 +384,9 @@ line that matters: no matrix reaches an installed tree. ### A file in a skill directory that nothing governs -The matrix disposes of `SKILL.md` and opens no other file, so a second file -beside it installs ungraded on every pathway. A `SOURCE.md` shipped that way -for four releases, carrying numbered procedures at whoever read it. +The matrix used to dispose of `SKILL.md` and to open no other file, so a second +file beside it installed ungraded on every pathway. A `SOURCE.md` shipped that +way for four releases, carrying numbered procedures at whoever read it. So a skill directory ships `SKILL.md`, `LICENSE`, `agents/`, and `references/`, and `ground --check` refuses anything else by name. The source record moved to @@ -378,13 +405,15 @@ plain file. `copyFile` resolves a link, so a link called `LICENSE` ships the bytes on the other end of it and the allowlist would have passed it. This is the disposition a study already gives a link inside it. -`references/` is the one entry whose governance is owed. `SKILL.md` routes a +`references/` was the one entry whose governance was owed. `SKILL.md` routes a writer into it, so it is context and not an audit record, and the answer to -ungraded context is to grade it rather than to evict it. Until issue #99 lands, -every run prints how many files under `references/` no row disposes of. That -count is a note, like `audit-coverage` beside it. Do not promote it to an -error, and do not remove it to quiet the output. ADR-0025 records both -decisions. +ungraded context is to grade it rather than to evict it. ADR-0025 settled that +and printed a count in the meantime, because nothing could grade those files +while the walk refused a blockquote. Issue #99 landed both halves. A matrix now +disposes of each file under `references/`, and the count is an error naming the +matrix to write. Read that as the note being answered rather than removed: what +replaced it refuses more than it counted. ADR-0030 records the decision, and it +amends ADR-0025. ### Impurity in `src/` diff --git a/CHANGELOG.md b/CHANGELOG.md index 78153ef..f3e3262 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- A grounding matrix disposes of one file, and `ground --check` reads every file + a skill ships to a writer. `SKILL.md` keeps `grounding//.md`, and + a Markdown file under `references/` answers to a matrix that mirrors its path, + such as + `grounding/standards/simplified-technical-english/references/examples.md`. The + two STE reference files installed on every pathway with no row disposing of a + line in either, and one of them mapped real `Rule N.N` identifiers to topic + labels with no `G` row anywhere. They carry 124 rows between them now, six of + them `G` rows, every one `unaudited` and `unquoted`. A row space is per file + because `Our anchor` names a heading, and two files in one skill can carry the + same heading. A graded file with no matrix is an error naming the matrix to + write, and so is a matrix that grades no file. A file under `references/` that + is not Markdown is refused, because the walk reads Markdown alone. Every + finding names the file it came from, beside the skill. ADR-0030 records the + decision, and it amends ADR-0025's count from a note to an error. Issue #99. +- A blockquote is a unit the checker reads, rather than a construct it refuses. + It is one block, from its first marker at column 0 to the first line without + one, named by a designator such as `[quote 8f3a2b1c]` whose digest binds the + quoted lines. That is the disposition a table and a fenced block already have. + The old refusal was never about the marker: the walk merged the quote with its + contents, so a quoted list reached a row as a paragraph carrying its own + markers. A line directly under a quote is refused instead, because a reader + continues a quote over a line that carries prose, and the walk holds no state + to say when. Leave a blank line under a quote. An indented marker stays + refused. `skills/standards/simplified-technical-english/references/examples.md` + went from 113 units and 41 refusals to 113 units and none. ADR-0031 records + the decision, and `test/gfm-render.test.js` holds every claim in it against + `micromark`. - A grounding matrix names the reading its audits answer to, above its table, as `**Source version:**` and a pin. The pin joins the row digest, so moving the source on voids every audit in the file at once. A `G` row cites a rule diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c1469a..8b6f35b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -285,10 +285,38 @@ Write your `SKILL.md` in the Markdown the check models. Four forms pass: a blank line, any construct written at column 0, a line that continues the paragraph above it, and an indented code block that stands on its own. The check reads a line at a time and models no container, so it refuses every -other line and names it. A blockquote, an empty marker and an empty heading -are refused at column 0 as well, because the check does not read those either. -ADR-0016 gives the reason. Report a skill that needs a container to say what -it means on issue 37. +other line and names it. An empty marker and an empty heading are refused at +column 0 as well, because the check does not read those either. ADR-0016 gives +the reason. Report a skill that needs a container to say what it means on +issue 37. + +A blockquote is one block, and its row names a digest of what the quote holds. +Leave a blank line under the quote. The check refuses the line directly below +one, because a reader may keep that line inside the quote. ADR-0031 gives the +reason. + +## Grade every file the skill ships to a writer + +A skill directory ships `SKILL.md`, `LICENSE`, `agents/`, and `references/`. A +matrix disposes of `SKILL.md` and of every Markdown file under `references/`, +one matrix for each file. + +The matrix for `SKILL.md` is `grounding//.md`. The matrix for a +reference file mirrors that file's own path, under a directory named for the +skill: + +``` +skills/standards/demo/references/examples.md +grounding/standards/demo/references/examples.md +``` + +Write the reference matrix the way you write the skill's own. It carries the +same seven columns, its own quotation declaration, and its own source version +when it holds a `G` row. A reference file with no matrix fails the check, and +so does a matrix that grades no file. ADR-0030 gives the reason. + +A file under `references/` is Markdown. The walk reads Markdown alone, so +nothing can grade a file of another kind, and the check refuses one by name. ## Write under the skills diff --git a/README.md b/README.md index 5032109..75145b4 100644 --- a/README.md +++ b/README.md @@ -309,8 +309,18 @@ dictionary, which this repository does not ship. ## Grounding matrices -Each skill has a grounding matrix in `grounding/`. The matrix disposes of every -unit of content in the skill, and each row says what that unit claims. +Each file a skill ships to a writer has a grounding matrix in `grounding/`. The +matrix disposes of every unit of content in that file, and each row says what +that unit claims. + +One matrix disposes of one file. `SKILL.md` answers to +`grounding//.md`, and a file under `references/` answers to a +matrix that mirrors its path, such as +`grounding/standards/simplified-technical-english/references/examples.md`. A +row names a heading, and two files in one skill can carry the same heading, so +a shared row space would let a row claim the wrong occurrence. A file with no +matrix fails the check, and so does a matrix that grades no file. ADR-0030 +records the decision. Rows come in three kinds: @@ -375,8 +385,14 @@ is metadata for the agent harness rather than instruction for a reader. The check reads Markdown a line at a time, and it models no container. So it states the forms it reads: a blank line, any construct at column 0, a line that continues the paragraph above it, and an indented code block that stands on its -own. It refuses every other line and names it, rather than reading a blockquote -or a nested construct as the wrong unit. +own. It refuses every other line and names it, rather than reading a nested +construct as the wrong unit. + +A blockquote is one block, named by a digest of what it holds, as a table and a +fenced block are. The quote runs from its first marker to the first line +without one. Leave a blank line under it. A line directly below a quote is +refused, because a reader may keep that line inside the quote and the check +holds no state to say whether they do. ADR-0031 records the decision. A continuation line states what it may begin with. It carries a letter, a digit, or ordinary sentence punctuation. It also carries a backtick or a tilde, @@ -384,10 +400,9 @@ where the line opens no fenced block. Any other lead is refused, because a lead character alone cannot say whether the line opens a container. Write such a line so it begins with a word, or write the construct at column 0. -Three constructs are refused at column 0 as well, because the check reads none +Two constructs are refused at column 0 as well, because the check reads neither of them: -- A blockquote, whose contents the check reads as our own prose. - A heading with no text, such as `#`, which opens no section. - A list item with no content, such as `-`, which opens no item. diff --git a/docs/adr/0025-a-skill-directory-ships-what-something-governs.md b/docs/adr/0025-a-skill-directory-ships-what-something-governs.md index 7321f6e..3123798 100644 --- a/docs/adr/0025-a-skill-directory-ships-what-something-governs.md +++ b/docs/adr/0025-a-skill-directory-ships-what-something-governs.md @@ -63,7 +63,8 @@ ungraded context is to grade it, not to evict it. Grading it is not this decision. `examples.md` yields 113 content units, and 41 of its lines are blockquotes that the extractor refuses today, so a matrix -cannot cover it until that grammar admits them. Issue #99 carries the work. +cannot cover it until that grammar admits them. Issue #99 carries the work, and +ADR-0030 and ADR-0031 record how it landed. ## The count is a note, and it stays one @@ -76,6 +77,16 @@ number exists to report. Do not promote it to an error, which would fail every release until #99 lands. Do not remove it to quiet the output, which would hide what this decision could not finish. +**Amended 2026-08-14, when #99 landed.** A matrix disposes of every file under +`references/`, one matrix per file, so the reason above no longer holds: an +ungraded reference file is a defect a contributor can fix rather than a state +the repository is stuck in. The count is an error now, per file, naming the +matrix to write. ADR-0030 records the decision and the row-space question it +turned on, and ADR-0031 records the grammar change that made `examples.md` +gradeable. The two instructions above stand for any future note of this kind: +what replaced this one refuses more than it counted, and nothing was removed to +quiet the output. + ## What was rejected **Grade every installed file now.** The six source records carry 166 content diff --git a/docs/adr/0030-a-matrix-disposes-of-one-file.md b/docs/adr/0030-a-matrix-disposes-of-one-file.md new file mode 100644 index 0000000..5499543 --- /dev/null +++ b/docs/adr/0030-a-matrix-disposes-of-one-file.md @@ -0,0 +1,121 @@ +--- +type: adr +status: accepted +decided: 2026-08-14 +issues: [99] +--- + +# ADR-0030 — A matrix disposes of one file + +ADR-0025 settled that a skill directory ships only what something governs, and +it left one entry owed. `references/` is context that `SKILL.md` routes a writer +into while the writer works, so the answer to it is to grade it rather than to +evict it. Nothing graded it. `ground --check` opened `SKILL.md` by name and no +other file, so the two files under +`skills/standards/simplified-technical-english/references/` reached a user on +every install pathway with no row disposing of a line in either. One of them +carries a table of topic labels against real `Rule N.N` identifiers, which are +claims about the source that no `G` row anywhere answered for. Issue #99 states +it, and ADR-0025 printed a count in the meantime. + +## Decision + +A matrix disposes of ONE file. `ground --check` reads `SKILL.md` and every +Markdown file under `references/`, each against its own matrix. + +The matrix for `SKILL.md` stays at `grounding//.md`. The matrix for +any other graded file mirrors that file's own path, under a directory named for +the skill: + +``` +skills/standards/simplified-technical-english/references/examples.md +grounding/standards/simplified-technical-english/references/examples.md +``` + +A graded file with no matrix is an error, and so is a matrix under that +directory that grades no file the skill ships. + +## Which file a row belongs to + +The row space is what decides this, and it is the question issue #99 asks first. +`Our anchor` names a heading. Two files in one skill can carry the same heading, +and the skill's own `references/examples.md` carries `Contents` and `Procedure` +while `SKILL.md` carries `Purpose` and `Priorities`. A shared row space over two +files lets a row claim an occurrence in the file it was not written for, and the +check passes: the text matches, the anchor matches, and nothing else in the row +says which file the author meant. + +Three answers were open. + +**An eighth column naming the file.** The header and the delimiter carry seven +columns, every heading is checked by name, and an eighth cell is refused because +GFM drops it. Adding one is a change to every matrix, every row, and the render +contract, and it leaves the row space shared while making the file a cell a +typing mistake can move. + +**A qualified anchor**, such as `references/examples.md § Contents`. The anchor +is compared against the heading the walk read, so this makes the comparison +parse a path out of a cell, and a row that omits the prefix silently reads as +the skill's own file. + +**One matrix per file**, which is this decision. The file identity moves out of +the row and into the matrix's own path, where a filesystem holds it rather than +a cell. No column changes, no digest moves, and no recorded audit is touched. +The `Quotation` declaration and the `Source version` pin are per matrix already, +so each graded file declares what it may quote and which reading its audits +answer to, which is the honest shape when a reference file could answer to a +different edition than the skill. + +`SKILL.md` keeps its path because every document, test, release and published +command names it, and moving six files buys symmetry and costs a migration that +answers no defect. The mapping lives in `matrixPathFor` in `src/catalog.js`, and +it is the one place that knows the layout. + +## What the checker reads + +`checkAll` walked the skill directory already, to refuse a file nothing governs. +It now grades every file that walk returns which `isGraded` names, and each +finding carries the file it came from. The command prints that file beside the +skill, because an anchor is a heading and two files in one skill can produce the +same line. + +`references/` holds Markdown. A matrix disposes of the units a Markdown walk +reads, and the walk reads nothing else, so a file of another kind there is +refused by name. This is ADR-0025's allowlist one level down: the directory says +what may ship, and this says what may stand inside it. + +## The count was a note, and it is an error now + +ADR-0025 printed how many files under `references/` no row disposed of, as a +note that failed nothing, and it said not to promote the count to an error and +not to remove it. Both instructions carried the same reason: nothing could grade +those files, so an error would fail every release until #99 landed. This is #99. + +The condition the note reported is now an error, per file, with the path of the +matrix to write. That is the opposite of quieting the output. A green run over a +file nobody has graded was the gap the number existed to report, and the gap is +closed by refusing rather than by counting. ADR-0025 is amended to say so. + +## What this does not claim + +Every row these matrices add starts `unaudited` and `unquoted`. Six `G` rows +across the two files cite a rule, and nobody has read one of them against the +standard. The matrices declare `**Quotation:** forbidden` for the reason the +skill's own matrix does, so no rule text moves into a cell. + +Grading a claim does not check it. It records what the claim is, so a person can +read it against the source and stamp what they read. That is what the two files +had none of. + +## What would flip it + +Evidence that no installed user reads `references/`. ADR-0025 states this the +same way: the argument for keeping those files is that `SKILL.md` routes a +writer into them, and if that routing is dead weight then eviction is simpler +than grading. The matrices are then deleted with the files. + +A skill that ships a reference a Markdown walk cannot read — a diagram, a data +table, a schema — meets the refusal above and needs a decision about what +governs it, which is ADR-0025's question rather than this one's. + +Decided 2026-08-14. Issue #99 states the defect and the three questions. diff --git a/docs/adr/0031-a-blockquote-is-a-block.md b/docs/adr/0031-a-blockquote-is-a-block.md new file mode 100644 index 0000000..511e5d5 --- /dev/null +++ b/docs/adr/0031-a-blockquote-is-a-block.md @@ -0,0 +1,95 @@ +--- +type: adr +status: accepted +decided: 2026-08-14 +issues: [99] +--- + +# ADR-0031 — A blockquote is a block + +ADR-0016 refuses a construct the extractor does not model, and a blockquote at +column 0 was one of three it named. The reason was never the marker. The walk +read a quote's lines as the prose of the paragraph around them, so the container +and its contents merged: `> Make sure that the kit contains these parts:` over +four quoted list items reached one unit reading `> Make sure ... > > - one +gasket > - two clamps`, with the markers inside the author's sentence. + +That refusal is what stopped issue #99. `references/examples.md` is a +before-and-after guide written in blockquotes, and it produced 113 units and 41 +refusals, one for every quoted line. No matrix could cover it while the walk +refused the shape it is written in. + +## Decision + +The walk reads a blockquote as one BLOCK, from the first marker at column 0 to +the first line without one. Its unit is a designator, `[quote 8f3a2b1c]`, whose +digest names the contents, which is the disposition a table and a fenced block +already have. The exception at column 0 goes with it, because the grammar's +second form admits any construct there once the walk has a reading of it. + +This is ADR-0016's rule applied forwards. That ADR asks which form the checker +reads a line as, and whether the grammar admits that form. The answer for a +quote was that the checker read it as prose and the grammar rightly refused +that. Giving it a reading a reader agrees with removes the refusal, and it adds +no rule naming the shape. + +## What the digest holds and what it does not + +A row over `[quote 8f3a2b1c]` says what the quote is, and the digest binds every +line inside it, so no word can change under a recorded row. What the row cannot +do is show a reader what the quote says, which is true of a table and a fenced +block too. `AGENTS.md` already answers that: a rule written as a table is still +a rule, and the row grades it whole. + +Every quoted example in the shipped skill is graded as an `E` row rather than an +`N` row. The sentences inside a `Before` block are imperative — they are the +text to revise — and an `N` row over them would retire a directive from review +by calling it scenery. + +## Where the quote ends + +A reader ends a blockquote at a blank line and at a construct that interrupts a +paragraph. A reader CONTINUES it over a line that merely carries prose, which is +lazy continuation, and `micromark` keeps `Prose.`, `===`, an indented line and a +whole GFM table inside the quote that way. + +Whether laziness applies depends on the block open INSIDE the quote, and this +walk holds no container state at all. So the block ends at the first line +without a marker, and a non-blank line there is refused as `a line directly +under a blockquote`. Doubt reads as the strict case, as it does everywhere else +in this check. + +The cost is an over-refusal: a list, a fence, a thematic break and an HTML block +each end the quote for a reader whatever it holds, and the walk refuses them +anyway. The remedy is a blank line, every shipped file already has one, and +`test/gfm-render.test.js` pins the cost with the render beside it rather than +leaving it in this paragraph. A heading is the one follower the two readers +agree on with no blank line, and not because the walk decided it: the section +split takes a heading and everything under it into the next section. + +A marker indented one to three columns is still a quote to a reader, and it +stays refused as `a blockquote that does not begin at column 0`. The walk claims +a construct at column 0 before it looks at an indent, which is the disposition +an indented table already has. + +## The measurement + +`references/examples.md` yielded 113 units and 41 refusals before, and 113 units +and no refusal after. The count is the same number because each quote already +collapsed into one merged unit. What changed is what the unit IS: a block whose +digest names the quoted lines, rather than a paragraph carrying its own markers. + +The shipped catalogue is unchanged. No `SKILL.md` here writes a blockquote, so +the `checkAll` test that asserts no refusal says nothing about this path, and +the parser oracle is the only evidence either way. That blind spot is the one +ADR-0029 already records for the continuation grammar. + +## What would flip it + +A skill that needs the units INSIDE a quote graded separately — a quote holding +several rules, each needing its own row. The digest grades the block whole, so +that skill would be asking for container state, and the answer is ADR-0016's +flip condition rather than a rule for quotes: a real parser behind the +extractor, on issue 37. + +Decided 2026-08-14. Issue #99 carries the measurement that motivated it. diff --git a/docs/specs/2026-07-26-stylewright-design.md b/docs/specs/2026-07-26-stylewright-design.md index 9618f28..93d7187 100644 --- a/docs/specs/2026-07-26-stylewright-design.md +++ b/docs/specs/2026-07-26-stylewright-design.md @@ -260,8 +260,10 @@ stylewright/ references/ agents/openai.yaml grounding/ # repo only, never installs - standards/.md + standards/.md # disposes of SKILL.md + standards//references/.md # one matrix per reference file craft/.md + craft//references/.md source/ # repo only, never installs standards/.md craft/.md diff --git a/grounding/standards/simplified-technical-english/references/examples.md b/grounding/standards/simplified-technical-english/references/examples.md new file mode 100644 index 0000000..2fc1b84 --- /dev/null +++ b/grounding/standards/simplified-technical-english/references/examples.md @@ -0,0 +1,159 @@ +# Grounding: skills/standards/simplified-technical-english/references/examples.md + +Disposes of every unit of content in +`skills/standards/simplified-technical-english/references/examples.md`. +A `G` row traces to a numbered rule in ASD-STE100 Issue 9, January 2025. + +- A **`G` row** traces to the standard. The `Source rule` cell names the rule. +- An **`E` row** is our own editorial guidance. It traces to nothing, and its + `Source rule` cell is empty. An `E` row carries our authority, not the + standard's. +- An **`N` row** is narrative. It orients the reader and asserts no rule, so it + claims no authority at all. Its `Source rule` cell is empty. + +One matrix disposes of one file. The matrix for the skill itself is +`grounding/standards/simplified-technical-english.md`, and it carries the +doctrine these rows follow. ADR-0030 records why a second graded file gets a +second matrix rather than more rows in the first. + +This file is a before-and-after guide, so two lines were drawn to fill the +column. A unit that states a rule of the standard, as an instruction or as a +limit, is a `G` row, and it cites what the skill's own matrix cites for the same +claim. A unit that says what OUR example does is an `E` row, because it carries +our authority and not the standard's. + +Every quoted example is an `E` row. The sentences inside a `Before` block are +imperative, and they are the text to revise rather than an instruction to the +reader. An `N` row over them would retire a directive from review by calling it +scenery. Each of those rows names a digest of the quoted lines, so a quote +cannot be rewritten under a row that stands. + +This file stays in the repository. It does not install with the skill. + +**Quotation:** forbidden. ASD reserves all rights, and the owner approved +publication on 2026-08-04 on the stated condition that no rule text is +reproduced. The check refuses any cell in this file but `unquoted` while this +line stands. Lifting it is the owner's decision, and it is made by editing this +line and the source record at +`source/standards/simplified-technical-english.md`. + +**Source version:** ASD-STE100 Simplified Technical English, Issue 9, January +2025, read from the official PDF on 2026-07-26. A rule number is stable across +issues, so an audit below says nothing about a later one. Move this line when +the skill moves to a new issue, and every audit here goes stale at once. + +| ID | Our guidance | Our anchor | Source rule | Source text | Source location | Audited | +|---|---|---|---|---|---|---| +| N-01 | Simplified Technical English Revision Patterns | Simplified Technical English Revision Patterns | | | Section title, asserts no rule | | +| E-01 | These are original illustrative examples. They show revision patterns but do not reproduce the official standard or establish strict compliance. | Simplified Technical English Revision Patterns | | | Our boundary statement | | +| N-02 | Contents | Contents | | | Section title, asserts no rule | | +| N-03 | [One instruction per sentence](#one-instruction-per-sentence) | Contents | | | Navigation, asserts no rule | | +| N-04 | [Condition before command](#condition-before-command) | Contents | | | Navigation, asserts no rule | | +| N-05 | [Active voice and a named actor](#active-voice-and-a-named-actor) | Contents | | | Navigation, asserts no rule | | +| N-06 | [Action verbs instead of abstract nouns](#action-verbs-instead-of-abstract-nouns) | Contents | | | Navigation, asserts no rule | | +| N-07 | [Shorter multi-word nouns](#shorter-multi-word-nouns) | Contents | | | Navigation, asserts no rule | | +| N-08 | [One term for one item](#one-term-for-one-item) | Contents | | | Navigation, asserts no rule | | +| N-09 | [Unambiguous pronouns](#unambiguous-pronouns) | Contents | | | Navigation, asserts no rule | | +| N-10 | [Procedure and description limits](#procedure-and-description-limits) | Contents | | | Navigation, asserts no rule | | +| N-11 | [Warning and caution](#warning-and-caution) | Contents | | | Navigation, asserts no rule | | +| N-12 | [Vertical lists and connecting words](#vertical-lists-and-connecting-words) | Contents | | | Navigation, asserts no rule | | +| N-13 | One instruction per sentence | One instruction per sentence | | | Section title, asserts no rule | | +| N-14 | **Before** | One instruction per sentence | | | Label over an example, asserts no rule | | +| E-02 | [quote 014465f0] | One instruction per sentence | | | Our own example of the text to revise | | +| N-15 | **After** | One instruction per sentence | | | Label over an example, asserts no rule | | +| E-03 | [quote f89021e9] | One instruction per sentence | | | Our own revision, written to show the pattern | | +| N-16 | **Why** | One instruction per sentence | | | Label over an example, asserts no rule | | +| E-04 | Each sentence gives one instruction. The condition applies only to replacement, and repetition removes pronoun ambiguity. | One instruction per sentence | | | Our own explanation of the example | | +| N-17 | Condition before command | Condition before command | | | Section title, asserts no rule | | +| N-18 | **Before** | Condition before command | | | Label over an example, asserts no rule | | +| E-05 | [quote 61041583] | Condition before command | | | Our own example of the text to revise | | +| N-19 | **After** | Condition before command | | | Label over an example, asserts no rule | | +| E-06 | [quote b8e95e15] | Condition before command | | | Our own revision, written to show the pattern | | +| N-20 | **Why** | Condition before command | | | Label over an example, asserts no rule | | +| E-07 | The reader receives the necessary condition before the command. The value and unit do not change. | Condition before command | | | Our own explanation of the example | | +| N-21 | Active voice and a named actor | Active voice and a named actor | | | Section title, asserts no rule | | +| N-22 | **Before** | Active voice and a named actor | | | Label over an example, asserts no rule | | +| E-08 | [quote 590e706b] | Active voice and a named actor | | | Our own example of the text to revise | | +| N-23 | **After** | Active voice and a named actor | | | Label over an example, asserts no rule | | +| E-09 | [quote ef8234bb] | Active voice and a named actor | | | Our own revision, written to show the pattern | | +| N-24 | **Why** | Active voice and a named actor | | | Label over an example, asserts no rule | | +| E-10 | The revision names the actor for each action. Use the approved role name from the applicable procedure. | Active voice and a named actor | | | Our own explanation of the example | | +| N-25 | Action verbs instead of abstract nouns | Action verbs instead of abstract nouns | | | Section title, asserts no rule | | +| N-26 | **Before** | Action verbs instead of abstract nouns | | | Label over an example, asserts no rule | | +| E-11 | [quote df1b50d6] | Action verbs instead of abstract nouns | | | Our own example of the text to revise | | +| N-27 | **After** | Action verbs instead of abstract nouns | | | Label over an example, asserts no rule | | +| E-12 | [quote 6f291517] | Action verbs instead of abstract nouns | | | Our own revision, written to show the pattern | | +| N-28 | **Why** | Action verbs instead of abstract nouns | | | Label over an example, asserts no rule | | +| E-13 | The verb states the action directly. The revision removes an unnecessary noun phrase without changing the object of the examination. | Action verbs instead of abstract nouns | | | Our own explanation of the example | | +| N-29 | Shorter multi-word nouns | Shorter multi-word nouns | | | Section title, asserts no rule | | +| N-30 | **Before** | Shorter multi-word nouns | | | Label over an example, asserts no rule | | +| E-14 | [quote 343b3892] | Shorter multi-word nouns | | | Our own example of the text to revise | | +| N-31 | **After** | Shorter multi-word nouns | | | Label over an example, asserts no rule | | +| E-15 | [quote 95088bca] | Shorter multi-word nouns | | | Our own revision, written to show the pattern | | +| N-32 | **Why** | Shorter multi-word nouns | | | Label over an example, asserts no rule | | +| E-16 | Prepositional phrases separate the relationships in the noun stack. Confirm that the revised relationships match the product data. | Shorter multi-word nouns | | | Our own explanation of the example | | +| G-01 | When a long official term cannot change, introduce it before a short form: | Shorter multi-word nouns | Rule 2.2, Rule 8.2 | unquoted | Part 1, Sections 2 and 8 | unaudited | +| E-17 | [quote 1943e8d5] | Shorter multi-word nouns | | | Our own revision, written to show the pattern | | +| N-33 | One term for one item | One term for one item | | | Section title, asserts no rule | | +| N-34 | **Before** | One term for one item | | | Label over an example, asserts no rule | | +| E-18 | [quote 191b70a9] | One term for one item | | | Our own example of the text to revise | | +| N-35 | **After** | One term for one item | | | Label over an example, asserts no rule | | +| E-19 | [quote c26fc750] | One term for one item | | | Our own revision, written to show the pattern | | +| N-36 | **Why** | One term for one item | | | Label over an example, asserts no rule | | +| G-02 | One item has one name. Repetition is preferable to synonyms when the synonyms can suggest different parts. | One term for one item | Rule 1.11 | unquoted | Part 1, Section 1 | unaudited | +| N-37 | Unambiguous pronouns | Unambiguous pronouns | | | Section title, asserts no rule | | +| N-38 | **Before** | Unambiguous pronouns | | | Label over an example, asserts no rule | | +| E-20 | [quote ce71b8c2] | Unambiguous pronouns | | | Our own example of the text to revise | | +| N-39 | **After — examine the hose** | Unambiguous pronouns | | | Label over an example, asserts no rule | | +| E-21 | [quote ca8fe21a] | Unambiguous pronouns | | | Our own revision, written to show the pattern | | +| N-40 | **After — examine the pump** | Unambiguous pronouns | | | Label over an example, asserts no rule | | +| E-22 | [quote 232f0f36] | Unambiguous pronouns | | | Our own revision, written to show the pattern | | +| N-41 | **Why** | Unambiguous pronouns | | | Label over an example, asserts no rule | | +| E-23 | The source sentence does not identify the object of `it`. Select the applicable revision only after the technical intent is known. | Unambiguous pronouns | | | Our own explanation of the example | | +| N-42 | Procedure and description limits | Procedure and description limits | | | Section title, asserts no rule | | +| N-43 | Procedure | Procedure | | | Section title, asserts no rule | | +| N-44 | **Before** | Procedure | | | Label over an example, asserts no rule | | +| E-24 | [quote 8544acde] | Procedure | | | Our own example of the text to revise | | +| N-45 | **After** | Procedure | | | Label over an example, asserts no rule | | +| E-25 | [quote 67812f21] | Procedure | | | Our own revision, written to show the pattern | | +| N-46 | **Why** | Procedure | | | Label over an example, asserts no rule | | +| G-03 | The revision uses short imperative sentences and keeps one instruction in each sentence. Count a procedural sentence against the 20-word limit. | Procedure | Rule 5.1, Rule 5.2, Rule 5.3 | unquoted | Part 1, Section 5 | unaudited | +| N-47 | Description | Description | | | Section title, asserts no rule | | +| N-48 | **Before** | Description | | | Label over an example, asserts no rule | | +| E-26 | [quote f29a84a7] | Description | | | Our own example of the text to revise | | +| N-49 | **After** | Description | | | Label over an example, asserts no rule | | +| E-27 | [quote 4c8c1153] | Description | | | Our own revision, written to show the pattern | | +| N-50 | **Why** | Description | | | Label over an example, asserts no rule | | +| G-04 | The revision gives information gradually and separates related ideas. Count each descriptive sentence against the 25-word limit. | Description | Rule 6.1, Rule 6.3 | unquoted | Part 1, Section 6 | unaudited | +| N-51 | Warning and caution | Warning and caution | | | Section title, asserts no rule | | +| N-52 | Injury or death risk | Injury or death risk | | | Section title, asserts no rule | | +| N-53 | **Before** | Injury or death risk | | | Label over an example, asserts no rule | | +| E-28 | [quote c7b88fb3] | Injury or death risk | | | Our own example of the text to revise | | +| N-54 | **After** | Injury or death risk | | | Label over an example, asserts no rule | | +| E-29 | [quote 675c1348] | Injury or death risk | | | Our own revision, written to show the pattern | | +| N-55 | **Why** | Injury or death risk | | | Label over an example, asserts no rule | | +| G-05 | In ASD usage, an injury or death risk requires a warning, not a caution. Confirm the approved signal word, command, hazard, and consequence with the applicable safety source. | Injury or death risk | Rule 7.1, Rule 7.2, Rule 7.3 | unquoted | Part 1, Section 7 | unaudited | +| N-56 | Object-damage risk | Object-damage risk | | | Section title, asserts no rule | | +| N-57 | **Before** | Object-damage risk | | | Label over an example, asserts no rule | | +| E-30 | [quote 2461b2c2] | Object-damage risk | | | Our own example of the text to revise | | +| N-58 | **After** | Object-damage risk | | | Label over an example, asserts no rule | | +| E-31 | [quote bb3e9d7e] | Object-damage risk | | | Our own revision, written to show the pattern | | +| N-59 | **Why** | Object-damage risk | | | Label over an example, asserts no rule | | +| E-32 | The stated consequence is damage to an object. Confirm that no injury risk is omitted before selecting a caution. | Object-damage risk | | | Our own explanation of the example | | +| N-60 | Vertical lists and connecting words | Vertical lists and connecting words | | | Section title, asserts no rule | | +| N-61 | Vertical list | Vertical list | | | Section title, asserts no rule | | +| N-62 | **Before** | Vertical list | | | Label over an example, asserts no rule | | +| E-33 | [quote b08055ef] | Vertical list | | | Our own example of the text to revise | | +| N-63 | **After** | Vertical list | | | Label over an example, asserts no rule | | +| E-34 | [quote d163ca87] | Vertical list | | | Our own revision, written to show the pattern | | +| N-64 | **Why** | Vertical list | | | Label over an example, asserts no rule | | +| E-35 | The list makes the quantities easy to check. Apply the official punctuation and word-count rules before release. | Vertical list | | | Our own explanation of the example | | +| N-65 | Connecting words | Connecting words | | | Section title, asserts no rule | | +| N-66 | **Before** | Connecting words | | | Label over an example, asserts no rule | | +| E-36 | [quote 67961009] | Connecting words | | | Our own example of the text to revise | | +| N-67 | **After** | Connecting words | | | Label over an example, asserts no rule | | +| E-37 | [quote 96265fe9] | Connecting words | | | Our own revision, written to show the pattern | | +| N-68 | **Why** | Connecting words | | | Label over an example, asserts no rule | | +| E-38 | The connecting word states the relationship. Use it only when the cause-and-effect relation is verified. | Connecting words | | | Our own explanation of the example | | +| N-69 | Compliance boundary | Compliance boundary | | | Section title, asserts no rule | | +| E-39 | These examples are patterns for revision and explanation. For strict compliance, check the applicable writing rules, every general word in the official dictionary, and every technical term in the applicable terminology database. | Compliance boundary | | | Our boundary statement | | diff --git a/grounding/standards/simplified-technical-english/references/rule-navigation.md b/grounding/standards/simplified-technical-english/references/rule-navigation.md new file mode 100644 index 0000000..f99115b --- /dev/null +++ b/grounding/standards/simplified-technical-english/references/rule-navigation.md @@ -0,0 +1,55 @@ +# Grounding: skills/standards/simplified-technical-english/references/rule-navigation.md + +Disposes of every unit of content in +`skills/standards/simplified-technical-english/references/rule-navigation.md`. +A `G` row traces to a numbered rule in ASD-STE100 Issue 9, January 2025. + +- A **`G` row** traces to the standard. The `Source rule` cell names the rule. +- An **`E` row** is our own editorial guidance. It traces to nothing, and its + `Source rule` cell is empty. An `E` row carries our authority, not the + standard's. +- An **`N` row** is narrative. It orients the reader and asserts no rule, so it + claims no authority at all. Its `Source rule` cell is empty. + +One matrix disposes of one file. The matrix for the skill itself is +`grounding/standards/simplified-technical-english.md`, and it carries the +doctrine these rows follow. ADR-0030 records why a second graded file gets a +second matrix rather than more rows in the first. + +The table is one unit and one `G` row. It claims where each topic lives in the +standard, and a claim about the source is what a `G` row is for. Its rule cell +names the whole range the table covers, because the table covers a range. The +row cites the reading recorded in +`source/standards/simplified-technical-english.md`, and its `Audited` cell says +that nobody has checked one of those locations against the standard. + +The steps under `Source-use boundary` are `E` rows. They tell a reader how to +use this map, and they state no rule of the standard. + +This file stays in the repository. It does not install with the skill. + +**Quotation:** forbidden. ASD reserves all rights, and the owner approved +publication on 2026-08-04 on the stated condition that no rule text is +reproduced. The check refuses any cell in this file but `unquoted` while this +line stands. Lifting it is the owner's decision, and it is made by editing this +line and the source record at +`source/standards/simplified-technical-english.md`. + +**Source version:** ASD-STE100 Simplified Technical English, Issue 9, January +2025, read from the official PDF on 2026-07-26. A rule number is stable across +issues, so an audit below says nothing about a later one. Move this line when +the skill moves to a new issue, and every audit here goes stale at once. + +| ID | Our guidance | Our anchor | Source rule | Source text | Source location | Audited | +|---|---|---|---|---|---|---| +| N-01 | ASD-STE100 Issue 9 Rule Navigation | ASD-STE100 Issue 9 Rule Navigation | | | Section title, asserts no rule | | +| E-01 | Use this map to find relevant material in the official standard. The topic labels below are paraphrases, not rule text. Search the [official Issue 9 PDF](https://www.asd-ste100.org/assets/files/ASD-STE100_ISSUE9.pdf) by rule identifier and confirm the complete rule, explanation, and applicable dictionary entries there. | ASD-STE100 Issue 9 Rule Navigation | | | Our own routing instruction, not a rule of the standard | | +| N-02 | Writing-rule map | Writing-rule map | | | Section title, asserts no rule | | +| G-01 | [table 5f581720] | Writing-rule map | Rules 1.1 through 9.4, and Part 2 | unquoted | Part 1, Sections 1 to 9, and Part 2 | unaudited | +| N-03 | Source-use boundary | Source-use boundary | | | Section title, asserts no rule | | +| E-02 | This navigator does not establish compliance. For strict compliance: | Source-use boundary | | | Our boundary statement | | +| E-03 | Open the official Issue 9 PDF. | Source-use boundary | | | Our own routing instruction, not a rule of the standard | | +| E-04 | Read each applicable rule and its explanation in full. | Source-use boundary | | | Our own routing instruction, not a rule of the standard | | +| E-05 | Check every general word in the controlled dictionary. | Source-use boundary | | | Our own routing instruction, not a rule of the standard | | +| E-06 | Check every technical noun and verb in the applicable terminology database. | Source-use boundary | | | Our own routing instruction, not a rule of the standard | | +| E-07 | Preserve product data, approved safety language, units, identifiers, labels, and quotations. | Source-use boundary | | | Our editing-safety check, not a writing rule | | diff --git a/source/standards/simplified-technical-english.md b/source/standards/simplified-technical-english.md index e343f85..afa8fa0 100644 --- a/source/standards/simplified-technical-english.md +++ b/source/standards/simplified-technical-english.md @@ -20,6 +20,14 @@ This file stays in the repository. It does not install with the skill. continued publication on 2026-08-04. The approval holds while the skill reproduces no rule text and no substantial part of the standard. A change that crosses either line reopens the decision. +- Reference files, 2026-08-14: the two files under `references/` gained matrices + of their own, at `grounding/standards/simplified-technical-english/references/`. + They carry six `G` rows between them, and every one reads `unquoted` and + `unaudited`. Both matrices declare quotation forbidden, in the words the + skill's matrix uses, so the publication decision below governs them without + change. Nobody read the copyright page or any rule on this date. The rule + identifiers those rows cite came from the reading of 2026-07-26 recorded here, + and no row claims that anybody has checked one. - Quotation check, 2026-08-06: the grounding matrix gained a `Source text` column on this date, under ADR-0020. Nothing was quoted into it. This record was read first, and the publication decision above forbids it: reproducing a diff --git a/src/catalog.js b/src/catalog.js index 81d53a9..9761fc6 100644 --- a/src/catalog.js +++ b/src/catalog.js @@ -3,6 +3,41 @@ import path from 'node:path'; export const TIERS = ['standards', 'craft']; +/** The directory whose files a matrix disposes of, one file at a time. */ +export const GRADED_DIR = 'references'; + +/** + * Whether a matrix disposes of this file, by its path inside the skill. + * + * `SKILL.md` and every Markdown file under `references/` are graded, and each + * of them one matrix at a time. `agents/` is not: a harness reads it as + * metadata, the way it reads front matter, and the Markdown walk cannot read + * YAML at all. `LICENSE` is not: it is a legal notice carrying no rule for a + * writer. + */ +export const isGraded = (rel) => rel === 'SKILL.md' + || (rel.startsWith(`${GRADED_DIR}/`) && rel.endsWith('.md')); + +/** + * Where the matrix for one file of a skill lives. + * + * A matrix disposes of ONE file, and the file it disposes of is the one its own + * path names. That is what keeps the row space separate once a skill carries + * more than one graded file: `Our anchor` names a heading, and two files in one + * skill can carry the same heading, so a shared row space would let a row claim + * an occurrence in the wrong file and the check would still pass. Issue #99 + * asked the question and ADR-0030 records the answer. + * + * `SKILL.md` keeps the path every document, test and release already names. + * Every other graded file mirrors its own path under a directory named for the + * skill, so the mapping is one join and a reader finds the matrix by spelling + * out the file. + */ +export function matrixPathFor(skill, rel) { + if (rel === 'SKILL.md') return skill.groundingPath; + return path.join(skill.groundingDir, ...rel.split('/')); +} + /** * The line ending is not a signal about the content, so it is removed before * anything reads the text. `.gitattributes` governs a checkout of this @@ -86,6 +121,7 @@ export async function loadCatalog(repoRoot) { dir, description: fm.description ?? '', groundingPath: path.join(repoRoot, 'grounding', tier, `${name}.md`), + groundingDir: path.join(repoRoot, 'grounding', tier, name), }); } } diff --git a/src/cli.js b/src/cli.js index 1ea9bfc..e19d86e 100644 --- a/src/cli.js +++ b/src/cli.js @@ -267,10 +267,14 @@ export async function run(argv, ctx) { // nothing a gate can act on, because no run of this program can raise it. // Counting the printed lines instead made the two indistinguishable, and // the number that says how little has been checked would have failed CI. + // The file a finding came from is printed beside the skill. A skill carries + // more than one graded file now, and an anchor is a heading, so two files + // in one skill can produce the same line. A finding about the directory + // rather than about a file names no file, and prints without one. let failed = 0; for (const name of names) { for (const f of all[name] ?? []) { - say(`${name}: ${f.code}: ${f.message}`); + say(`${name}: ${f.file ? `${f.file}: ` : ''}${f.code}: ${f.message}`); if (f.level !== 'note') failed++; } } diff --git a/src/ground.js b/src/ground.js index 41439a7..d301459 100644 --- a/src/ground.js +++ b/src/ground.js @@ -2,7 +2,9 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import crypto from 'node:crypto'; import { sections, indentOf, isIndented, columnOf } from './markdown.js'; -import { loadCatalog } from './catalog.js'; +import { + loadCatalog, isGraded, matrixPathFor, GRADED_DIR, +} from './catalog.js'; import { walk } from './tree.js'; /** @@ -215,8 +217,8 @@ export function parseMatrix(text) { const digest = (s) => crypto.createHash('sha256').update(s).digest('hex').slice(0, 8); /** - * A table and a fenced block do not fit in a matrix cell, so each is named by a - * designator instead. + * A table, a fenced block and a blockquote do not fit in a matrix cell, so each + * is named by a designator instead. * * The designator carries a digest of the block rather than a number. An * ordinal identified a POSITION, so the contents of a table could be replaced @@ -225,7 +227,7 @@ const digest = (s) => crypto.createHash('sha256').update(s).digest('hex').slice( * digest identifies the CONTENT: edit the block and its row stops matching, * which is what every other row already does. */ -const DESIGNATOR = /^\[(?:table|code) [0-9a-f]{8}\]$/; +const DESIGNATOR = /^\[(?:table|code|quote) [0-9a-f]{8}\]$/; /** * What a `G` row records about its own audit. @@ -734,6 +736,24 @@ const EMPTY_HEADING = /^#{1,6}\s*$/; // below states which reading that is and why this check takes it. const ORDERED = /^(\d{1,9})[.)](?:\s|$)/; +/** + * A blockquote, at column 0 and nowhere else. + * + * The walk reads the quote as ONE block, from this line to the first line that + * does not carry the marker. A reader sees one block there too, and what sits + * inside it — a paragraph, a list, a table, a heading — is the container state + * this walk holds none of. So the quote is disposed of the way a table and a + * fenced block already are, by a digest of its contents, and nothing inside it + * can be grounded as something else. That is what the refusal used to protect: + * `> Make sure that the kit contains these parts:` over four quoted list items + * reached one matrix row as a paragraph carrying its own markers. ADR-0031. + * + * A marker indented one to three columns is still a quote to a reader, and it + * stays refused, because the walk claims a construct at column 0 before it + * looks at an indent. That is the disposition an indented table already has. + */ +const OPENS_QUOTE = /^>/; + /** * Whether a marker on this line opens a list WHERE IT STANDS. * @@ -786,12 +806,18 @@ const CODE_PADDING = 5; // the same rule. Two copies of it gave one file two readings. /** - * The three constructs refused at column 0, named once. README lists them for - * a contributor, and a test holds that list against this one, because a - * document that names two of three teaches an author to write the third. + * The constructs refused at column 0, named once. README lists them for a + * contributor, and a test holds that list against this one, because a document + * that names one of two teaches an author to write the other. + * + * A blockquote was the third of these until the walk was given a reading of + * one. It is now a block, like a table and a fenced block, and ADR-0031 records + * why: the refusal was never about the marker, it was that the walk merged the + * quote with its contents and read `> - one gasket` as a paragraph's own words. + * A reading that agrees with the reader removes the exception, which is + * ADR-0016's rule applied forwards rather than another rule naming a shape. */ export const AT_COLUMN_ZERO = { - blockquote: 'a blockquote', heading: 'a heading with no text', item: 'a list item with no content', }; @@ -900,9 +926,6 @@ function outsideGrammar(line, { startsBlock, openText, openItem, listOpen, opensFence, opensTable, }) { if (!line.trim()) return null; - // A blockquote is the one construct at column 0 whose contents the extractor - // reads as its own prose, so the quote and its container merge. - if (indentOf(line) === 0 && line.startsWith('>')) return AT_COLUMN_ZERO.blockquote; // An empty heading is refused wherever it appears, because a heading does // interrupt a paragraph for a Markdown reader, and neither the section scan // nor the walk reads one. `Prose` over `#` over a directive was one unit. @@ -978,6 +1001,24 @@ function unitsIn(body, anchor, refuse = () => {}) { if (!line.trim() || isIndented(line)) { block.lines.push(line); continue; } closeBlock(); } + // The quote's own contents, settled before anything else looks at the line, + // for the reason an indented block's are. A fence marker, a table row and a + // list marker all appear inside a quote in this repository's own examples, + // and each of them is the quote's content rather than a block of its own. + // + // The block ends at the first line without a marker. A reader ends it at a + // blank line and at a construct that interrupts a paragraph, and CONTINUES + // it over a line that merely carries prose: `> Quoted.` over `Prose.` is one + // paragraph inside the quote, and so are a table's own lines. Which of those + // a reader does depends on what block is open INSIDE the quote, which this + // walk holds no state for, so the line is refused rather than guessed at. + // Doubt reads as the strict case here as it does everywhere else, and the + // author writes the blank line every shipped file already has. + if (block?.kind === 'quote') { + if (OPENS_QUOTE.test(line)) { block.lines.push(line); continue; } + closeBlock(); + if (line.trim()) refuse(i, 'a line directly under a blockquote'); + } const fence = FENCE.exec(line); // Close only on the SAME marker, at least as long. A four-backtick fence // around a three-backtick example was closed by the example's own opening @@ -1035,6 +1076,17 @@ function unitsIn(body, anchor, refuse = () => {}) { block = { kind: 'code', lines: [fence[3].trim()], marker: fence[2] }; continue; } + // A quote opens where a reader opens one, which includes under open prose: + // a blockquote interrupts a paragraph, so the paragraph is flushed rather + // than continued. The list above it has ended for the same reason a fence + // ends one. + if (OPENS_QUOTE.test(line)) { + listOpen = false; + flush(); + closeBlock(); + block = { kind: 'quote', lines: [line] }; + continue; + } // A table row need not start with a pipe. `Name | Meaning` over // `--- | ---` is a table, and reading it as prose left it with no // designator and no way to be quoted in a cell. @@ -1155,8 +1207,8 @@ export function contentUnits(skillText) { * says the check misread the line. */ function remedyFor(shape) { - if (shape.startsWith('a blockquote')) { - return 'Write the quoted words as our own prose, or put them in a fenced block.'; + if (shape === 'a line directly under a blockquote') { + return 'Leave a blank line under the quote, or carry the marker on to this line.'; } if (shape === 'a heading with no text') return 'Give the heading its text, or delete the line.'; if (shape === 'a list item with no content') return 'Give the item its words, or delete the marker.'; @@ -1212,10 +1264,25 @@ const BROKEN = new Set([ 'row-outside-the-table', ]); -export function checkSkill({ skillText, matrixText, now }) { +/** + * One graded file against its own matrix. + * + * `subject` is the file being graded, and it names the file in the findings + * that speak about it. It defaults to `SKILL.md`, which is what this check read + * until a skill's reference files were graded as well. `matrixPath` is where + * the matrix for that file belongs, and it is a display path rather than + * anything this function opens. + */ +export function checkSkill({ + skillText, matrixText, now, subject = 'SKILL.md', matrixPath = null, +}) { const today = dayOf(now); if (matrixText === null || matrixText === undefined) { - return [{ level: 'error', code: 'no-matrix', message: 'Skill has no grounding matrix.' }]; + return [{ + level: 'error', + code: 'no-matrix', + message: `no grounding matrix disposes of ${subject}.${matrixPath ? ` Write one at ${matrixPath}.` : ''}`, + }]; } const rows = parseMatrix(matrixText); // The rows that claim a source. The coverage note counts them at the end, and @@ -1488,8 +1555,8 @@ export function checkSkill({ skillText, matrixText, now }) { level: 'error', code: spent ? 'duplicate-row' : 'missing-quote', message: spent - ? `${row.id}: "${row.guidance}" appears fewer times in SKILL.md than the matrix claims.` - : `${row.id}: "${row.guidance}" no longer appears in SKILL.md.`, + ? `${row.id}: "${row.guidance}" appears fewer times in ${subject} than the matrix claims.` + : `${row.id}: "${row.guidance}" no longer appears in ${subject}.`, }); } else if (hit.anchor !== row.anchor) { findings.push({ @@ -1746,26 +1813,27 @@ export const SHIPPED_FILES = { /** * Directories a skill may ship, and what governs what is inside them. * - * `references/` is the one entry whose governance is owed rather than held. + * `references/` was the one entry whose governance was owed rather than held. * The skill routes an agent into it while the agent writes, so it is context * and not an audit record, and the answer to ungraded context is to grade it - * rather than to evict it. ADR-0025 records that, and issue #99 carries the - * work. Until it lands, every run counts what those files hold. + * rather than to evict it. ADR-0025 recorded that and could not do the work, + * so every run counted the files instead. ADR-0030 does the work: a matrix + * disposes of each of those files, one matrix per file, and a file with no + * matrix is an error rather than a number in a note. */ export const SHIPPED_DIRS = { agents: 'a harness reads it as metadata, the way it reads front matter', - references: 'the skill routes a writer into it, and no matrix disposes of it yet', + references: 'a matrix outside the skill disposes of every unit in each file', }; /** * The files in one skill directory, against that list. Pure, so the walk that * finds them stays in the caller. * - * The reference count is a note, for the reason `audit-coverage` is one: no - * run of this program can raise it, and a gate that fails on it would fail - * every release until issue #99 lands. Do not promote it to an error, and do - * not remove it to quiet the output. A green run over files nothing grades is - * the thing this number exists to report. + * `references/` holds Markdown, because a matrix disposes of what the Markdown + * walk reads and the walk reads nothing else. A file of another kind there is + * refused by name rather than counted, which is the disposition ADR-0025 gives + * a file nothing governs and ADR-0030 extends to a file nothing CAN grade. */ export function checkShippedFiles({ files, irregular = [], tier, name }) { const allowed = Object.entries(SHIPPED_FILES) @@ -1797,28 +1865,76 @@ export function checkShippedFiles({ files, irregular = [], tier, name }) { + 'matrix and outside every copy. Anything else needs a decision about what ' + 'governs it before it ships.', }))); - const referenced = files.filter((rel) => rel.startsWith('references/')); - if (referenced.length) { - findings.push({ - level: 'note', - code: 'reference-coverage', - message: `${referenced.length} file(s) under references/ install with this skill and ` - + `no row disposes of them: ${referenced.join(', ')}.`, - }); - } + findings.push(...files + .filter((rel) => rel.startsWith(`${GRADED_DIR}/`) && !isGraded(rel)) + .map((rel) => ({ + level: 'error', + code: 'reference-not-markdown', + message: `${rel} ships with this skill, and no matrix can dispose of it. A matrix ` + + 'disposes of the units a Markdown walk reads, and the walk reads Markdown alone. ' + + `Write it as Markdown under ${GRADED_DIR}/, or find it a home that governs it.`, + }))); return findings; } +/** + * The matrix at a path, or nothing where no matrix stands there. + * + * A directory at that path is nothing too. The finding then names the path and + * says a matrix belongs there, which is what the author has to act on either + * way, and a raw `EISDIR` would stop the whole run over one skill. + */ +const matrixAt = async (file) => { + try { + return await fs.readFile(file, 'utf8'); + } catch (err) { + if (!['ENOENT', 'EISDIR'].includes(err.code)) throw err; + return null; + } +}; + +/** + * Every matrix a skill carries that disposes of no file it ships. + * + * The mirror of a graded file with no matrix, and the reason it is an error + * rather than a tidy-up: a matrix nothing reads goes stale unnoticed, and a + * reference file renamed under one leaves the old rows passing every check + * here forever, because no check opens a file nobody names. + */ +async function orphanMatrices(skill, graded) { + const held = new Set(graded.map((rel) => matrixPathFor(skill, rel))); + let found = []; + try { + found = await walk(skill.groundingDir); + } catch (err) { + // Nothing there is the ordinary case. A file there is a shape this walk + // does not model, and it is named rather than thrown, because one odd path + // must not stop the run over every other skill. + if (err.code === 'ENOENT') return []; + if (err.code !== 'ENOTDIR') throw err; + return [{ + level: 'error', + code: 'matrix-grades-nothing', + message: `${path.basename(skill.groundingDir)} is where this skill's reference matrices ` + + 'live, and it is a file. A matrix names the file it grades by its own path, so this ' + + 'one grades nothing. Make it a directory, or delete it.', + }]; + } + return found + .map((rel) => path.join(skill.groundingDir, ...rel.split('/'))) + .filter((file) => !held.has(file)) + .map((file) => ({ + level: 'error', + code: 'matrix-grades-nothing', + message: `${path.relative(path.dirname(skill.groundingDir), file)} disposes of no file ` + + 'this skill ships. A matrix names the file it grades by its own path, so either the ' + + 'file moved and this matrix did not, or the matrix is left over. Move it or delete it.', + })); +} + export async function checkAll(repoRoot, { now } = {}) { const out = {}; for (const skill of await loadCatalog(repoRoot)) { - const skillText = await fs.readFile(path.join(skill.dir, 'SKILL.md'), 'utf8'); - let matrixText = null; - try { - matrixText = await fs.readFile(skill.groundingPath, 'utf8'); - } catch (err) { - if (err.code !== 'ENOENT') throw err; - } // `walk` returns names, and a name says nothing about what stands at it. // The type is asked for here, with `lstat`, so the link itself answers // rather than whatever it points at. @@ -1828,12 +1944,33 @@ export async function checkAll(repoRoot, { now } = {}) { const st = await fs.lstat(path.join(skill.dir, rel)); if (!st.isFile()) irregular.push(rel); } - out[skill.name] = [ - ...checkShippedFiles({ - files, irregular, tier: skill.tier, name: skill.name, - }), - ...checkSkill({ skillText, matrixText, now }), - ]; + const findings = checkShippedFiles({ + files, irregular, tier: skill.tier, name: skill.name, + }); + // One matrix per graded file, and each finding carries the file it came + // from. A skill used to carry one graded file, so the file went without + // saying. It does not now: two files in one skill can hold the same + // heading, and a reader given the anchor alone cannot tell which one a + // finding is about. + // + // A file the check has already refused for not being a plain file is not + // read. `readFile` resolves a link, so it would grade bytes from wherever + // the link points, and a FIFO at a graded path would hang the run rather + // than fail it. That is the reading `src/doctor.js` gives an instruction + // file, and the refusal above is the finding either way. + const graded = files.filter((rel) => isGraded(rel) && !irregular.includes(rel)); + for (const rel of graded) { + const matrixPath = matrixPathFor(skill, rel); + findings.push(...checkSkill({ + skillText: await fs.readFile(path.join(skill.dir, ...rel.split('/')), 'utf8'), + matrixText: await matrixAt(matrixPath), + now, + subject: rel, + matrixPath: path.relative(repoRoot, matrixPath), + }).map((f) => ({ ...f, file: rel }))); + } + findings.push(...await orphanMatrices(skill, graded)); + out[skill.name] = findings; } return out; } diff --git a/test/catalog.test.js b/test/catalog.test.js index 9281165..0c0abd1 100644 --- a/test/catalog.test.js +++ b/test/catalog.test.js @@ -3,7 +3,9 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { loadCatalog, readFrontmatter } from '../src/catalog.js'; +import { + loadCatalog, readFrontmatter, isGraded, matrixPathFor, +} from '../src/catalog.js'; import { contained } from '../src/manifest.js'; import { walk } from '../src/tree.js'; @@ -78,6 +80,30 @@ test('grounding path points outside the skill directory', async () => { assert.ok(!skill.groundingPath.startsWith(skill.dir)); }); +test('a matrix is found by the path of the file it grades', async () => { + // One matrix disposes of one file, and the file it disposes of is the one its + // own path names. `SKILL.md` keeps the path every document and release + // already names, and every other graded file mirrors its own path. + const cat = await loadCatalog(REPO); + const skill = cat.find((s) => s.name === 'demo-standard'); + assert.equal(matrixPathFor(skill, 'SKILL.md'), skill.groundingPath); + assert.ok(matrixPathFor(skill, 'references/patterns.md') + .endsWith(path.join('grounding', 'standards', 'demo-standard', 'references', 'patterns.md'))); + assert.ok(!matrixPathFor(skill, 'references/patterns.md').startsWith(skill.dir)); +}); + +test('a matrix disposes of SKILL.md and of Markdown under references, and nothing else', () => { + // `agents/` is metadata a harness reads, the way it reads front matter, and + // the Markdown walk cannot read YAML at all. `LICENSE` carries no rule for a + // writer. Both are governed by what they are, and neither is graded. + assert.ok(isGraded('SKILL.md')); + assert.ok(isGraded('references/examples.md')); + assert.ok(isGraded('references/deeper/examples.md')); + for (const rel of ['LICENSE', 'agents/openai.yaml', 'references/table.csv', 'README.md']) { + assert.ok(!isGraded(rel), `${rel} is not graded`); + } +}); + test('frontmatter name must match directory name', async () => { const cat = await loadCatalog(REPO); for (const s of cat) assert.equal(path.basename(s.dir), s.name); diff --git a/test/gfm-render.test.js b/test/gfm-render.test.js index f723a7d..caff458 100644 --- a/test/gfm-render.test.js +++ b/test/gfm-render.test.js @@ -32,13 +32,22 @@ import { renderTables, renderBlocks, cellText } from './gfm.js'; const GROUNDING = new URL('../grounding/', import.meta.url); const NOW = '2026-08-06T12:00:00.000Z'; -async function matrices() { +/** + * Every matrix in the grounding tree, however deep it sits. + * + * A skill's reference files are graded one matrix per file, under a directory + * named for the skill, so a scan of the tier directory alone stopped at the + * directory and read none of them. The whole tree is walked instead, which is + * what "every shipped matrix" has to mean for the test below to be true. + */ +async function matrices(dir = GROUNDING, base = '') { const found = []; - for (const tier of await readdir(GROUNDING)) { - const dir = new URL(`${tier}/`, GROUNDING); - for (const name of await readdir(dir)) { - if (!name.endsWith('.md')) continue; - found.push({ name: `${tier}/${name}`, text: await readFile(new URL(name, dir), 'utf8') }); + for (const entry of await readdir(dir, { withFileTypes: true })) { + const rel = base ? `${base}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + found.push(...await matrices(new URL(`${entry.name}/`, dir), rel)); + } else if (entry.name.endsWith('.md')) { + found.push({ name: rel, text: await readFile(new URL(entry.name, dir), 'utf8') }); } } return found; @@ -87,7 +96,9 @@ description: d test('every shipped matrix renders as one table the checker read exactly', async () => { const found = await matrices(); - assert.ok(found.length >= 6, 'the grounding directory carries the shipped matrices'); + assert.ok(found.length >= 8, 'the grounding tree carries the shipped matrices'); + assert.ok(found.some((m) => m.name.includes('/references/')), + 'and the reference matrices among them, which a flat scan missed'); for (const { name, text } of found) { const tables = renderTables(text); assert.equal(tables.length, 1, `${name}: a reader sees exactly one table`); @@ -437,3 +448,76 @@ test('a table a reader sees without a pipe is refused, and a heading is not', () assert.doesNotMatch(renderBlocks('Prose here.\n\n:-'), //); assert.deepEqual(refusalsFor('Prose here.\n\n:-'), []); }); + +/** + * The blockquote, read as a block, checked against the same parser. + * + * The walk refused a blockquote until issue #99, because it merged the quote + * with its contents: `> - one gasket` reached a matrix row as a paragraph + * carrying its own markers. It reads one BLOCK now, named by a digest of what + * the quote holds, which is the disposition a table and a fenced block already + * have. So the claim to check is the one ADR-0028 asks for: where a reader sees + * one blockquote, the walk reads one block, and nothing inside it reaches a + * unit of its own. + */ +const blockquotes = (text) => (renderBlocks(text).match(/
/g) ?? []).length; + +test('where a reader sees one blockquote, the walk reads one block', () => { + // Each of these holds a construct the walk reads as a block of its own at + // column 0. Inside the quote it is the quote's content, and a reader agrees. + for (const text of [ + '> Quoted.', + '> One.\n>\n> Two.', + '> Intro:\n>\n> - one gasket\n> - two clamps', + '> ```js\n> const x = 1;\n> ```', + '> | a | b |\n> |---|---|', + '> # A heading inside the quote', + ]) { + assert.equal(blockquotes(text), 1, `a reader sees one quote in ${JSON.stringify(text)}`); + // The heading is a unit of its own, so the section's body starts after it. + const units = contentUnits(skillWith(text)) + .filter((u) => u.anchor === 'Later' && u.text !== 'Later'); + assert.deepEqual(units.map((u) => u.block), [true], + `the walk reads one block in ${JSON.stringify(text)}: ${JSON.stringify(units)}`); + assert.match(units[0].text, /^\[quote [0-9a-f]{8}\]$/); + assert.deepEqual(refusalsFor(text), []); + } +}); + +test('a line under a blockquote is refused, and the render says why it must be', () => { + // A reader CONTINUES the quote over a line that carries prose, and over a + // table's own lines, so reading those at the top level would ground the + // quote's contents as something else. + for (const follower of ['Prose here.', '===', '| a | b |\n|---|---|', ' indented']) { + const text = `> Quoted.\n${follower}`; + assert.match(renderBlocks(text), /
[\s\S]*Prose here\.|
[\s\S]*===|
[\s\S]*a \| b|
[\s\S]*indented/, + `a reader keeps ${JSON.stringify(follower)} inside the quote`); + assert.ok(refusalsFor(text).includes('a line directly under a blockquote'), + `the check refuses ${JSON.stringify(text)}: ${JSON.stringify(refusalsFor(text))}`); + } +}); + +test('the over-refusal under a blockquote is pinned, because a reader ends it there', () => { + // The other direction, stated rather than hidden. A construct that interrupts + // a paragraph ends the quote for a reader whatever the quote holds, so these + // lines need no refusal. The walk refuses them anyway: whether a line is lazy + // continuation depends on the block open INSIDE the quote, and the walk holds + // no container state to answer with. The cost is a blank line the author + // writes, and every shipped file already has one there. + for (const follower of ['- item', '1. item', '```\ncode\n```', '---', '
x
']) { + const text = `> Quoted.\n${follower}`; + assert.equal(blockquotes(text), 1); + assert.doesNotMatch(renderBlocks(text).split('
')[0], /item|code| Quoted.\n\n${follower}`), [], + 'a blank line is the whole remedy'); + } + // A heading is the one follower the two readers agree on with no blank line, + // and not because the walk decided it. The section split takes the heading and + // everything under it into the next section, so the quote ends at the end of + // the body and no line follows it there. + assert.deepEqual(refusalsFor('> Quoted.\n## Deeper\n\nProse.'), []); + assert.doesNotMatch(renderBlocks('> Quoted.\n## Deeper').split('
')[0], /Deeper/); +}); diff --git a/test/ground.test.js b/test/ground.test.js index 92955e8..e638119 100644 --- a/test/ground.test.js +++ b/test/ground.test.js @@ -1057,10 +1057,19 @@ test('a heading with leading spaces is refused, not merged into prose', () => { /a heading that does not begin at column 0/); }); -test('a list inside a blockquote is refused, not flattened into one unit', () => { - // Two directives were read as one paragraph, so one row disposed of both. - const found = refused('> - Do first.\n> - Do second.'); - assert.equal(found.match(/a blockquote/g).length, 2); +test('a list inside a blockquote is one block, and its digest names the list', () => { + // Two directives were read as one paragraph carrying its own markers, so one + // row disposed of both and the words inside could change under it. The quote + // is a block now, on the terms a table and a fenced block already have: one + // unit, named by a digest of its contents, so editing a line inside it stops + // the row matching. Issue #99. + const text = '> - Do first.\n> - Do second.'; + assert.equal(refused(text), ''); + const blocks = (t) => contentUnits(`${SKILL}\n## Later\n\n${t}\n`).filter((u) => u.block); + const units = blocks(text); + assert.equal(units.length, 1); + assert.match(units[0].text, /^\[quote [0-9a-f]{8}\]$/); + assert.notEqual(blocks('> - Do first.\n> - Do third.')[0].text, units[0].text); }); test('a list item indented under another is refused', () => { @@ -1070,8 +1079,25 @@ test('a list item indented under another is refused', () => { /a list item that does not begin at column 0/); }); -test('a fence inside a blockquote is refused', () => { - assert.match(refused('> ```js\n> const x = 1;\n> ```'), /a blockquote/); +test('a fence inside a blockquote is the quote\'s contents, not a block of its own', () => { + // The marker carries the quote, so the walk never reads the fence as an + // opener. One block, and the fence closes nothing outside it. + const text = '> ```js\n> const x = 1;\n> ```\n\nAlways preserve safety.'; + assert.equal(refused(text), ''); + const units = contentUnits(`${SKILL}\n## Later\n\n${text}\n`) + .filter((u) => u.anchor === 'Later'); + assert.equal(units.filter((u) => u.block).length, 1); + assert.ok(units.some((u) => u.text === 'Always preserve safety.')); +}); + +test('a line directly under a blockquote is refused, because a reader may keep it', () => { + // A reader continues the quote over a line that carries prose, and ends it at + // a construct that interrupts a paragraph. Which one depends on the block open + // INSIDE the quote, and this walk holds no container state, so it names the + // line instead of guessing. + assert.match(refused('> Quoted.\nAlways preserve safety.'), + /a line directly under a blockquote/); + assert.equal(refused('> Quoted.\n\nAlways preserve safety.'), ''); }); test('a table indented under a list item is refused', () => { @@ -1125,8 +1151,10 @@ test('an empty list marker opens a list, so its child block is refused', () => { test('a container prefix is refused before the line becomes a table', () => { // `> A | B` over `--- | ---` reached the table branch first and became a - // designator, so a blockquote passed the guard with no refusal at all. - assert.match(refused('> A | B\n--- | ---\n> c | d'), /a blockquote/); + // designator, so a blockquote passed the guard with no refusal at all. The + // quote is read as a quote now, and the delimiter under it is the line a + // reader may keep inside that quote. + assert.match(refused('> A | B\n--- | ---\n> c | d'), /a line directly under a blockquote/); assert.match(refused(' ## A | B\n--- | ---'), /a heading that does not begin at column 0/); assert.equal(refused('| a | b |\n|---|---|\n| c | d |'), ''); @@ -1403,7 +1431,7 @@ test('a refusal carries a remedy the author can follow', () => { }).find((f) => f.code === 'unmodelled-construct').message; for (const [text, remedy] of [ - ['> quoted', /fenced block/], + ['> quoted\nProse under it.', /Leave a blank line under the quote/], ['#', /Give the heading its text/], ['-', /Give the item its words/], ['- A | B\n--- | ---', /Move the table out of the list/], @@ -1428,9 +1456,10 @@ test('an indented construct with no list above it is code, and stands', () => { }); test('a refusal names the line in the file, front matter counted', () => { - const skillText = `${SKILL}\n> Quoted.\n`; - const line = skillText.split('\n').indexOf('> Quoted.') + 1; - assert.deepEqual(unmodelled(skillText), [{ line, shape: 'a blockquote' }]); + const skillText = `${SKILL}\n > Quoted.\n`; + const line = skillText.split('\n').indexOf(' > Quoted.') + 1; + assert.deepEqual(unmodelled(skillText), + [{ line, shape: 'a blockquote that does not begin at column 0' }]); assert.ok(check({ skillText, matrixText: MATRIX }) .some((f) => f.code === 'unmodelled-construct' && f.message.startsWith(`line ${line}:`))); }); @@ -1744,3 +1773,136 @@ test('what a skill directory may ship passes, and the shipped catalogue does', a .flatMap(([name, fs]) => fs.filter((f) => f.code === 'ungoverned-shipped-file') .map((f) => `${name}: ${f.message}`)), []); }); + +// A skill carries more than one graded file. `SKILL.md` was the only one, and +// `references/` shipped beside it with nothing disposing of a line. ADR-0025 +// settled that they are graded rather than evicted, and ADR-0030 says how: one +// matrix per file, at the path that mirrors the file, so no row can claim an +// occurrence in a file it was not written for. + +/** A copy of the fixture repository, with a reference file planted in it. */ +async function withReference(t, text, name = 'patterns.md') { + const repo = await fsp.mkdtemp(path.join(os.tmpdir(), 'sw-ref-')); + t.after(() => fsp.rm(repo, { recursive: true, force: true })); + await fsp.cp(REPO, repo, { recursive: true }); + const dir = path.join(repo, 'skills', 'standards', 'demo-standard', 'references'); + await fsp.mkdir(dir, { recursive: true }); + await fsp.writeFile(path.join(dir, name), text); + return repo; +} + +const REFERENCE = `# Patterns + +## Rules + +- Use no more than 20 words in a sentence. +`; + +const REFERENCE_MATRIX = `# Grounding: references/patterns.md + +${DECLARED} + +${PINNED} + +| ID | Our guidance | Our anchor | Source rule | Source text | Source location | Audited | +|---|---|---|---|---|---|---| +| N-01 | Patterns | Patterns | | | Section title | | +| N-02 | Rules | Rules | | | Section title | | +| G-01 | Use no more than 20 words in a sentence. | Rules | DEMO-4 | unquoted | The Demo Standard, clause 4 | unaudited | +`; + +test('a reference file with no matrix is refused, and the refusal names the path', async (t) => { + const repo = await withReference(t, REFERENCE); + const found = (await checkAll(repo, { now: NOW }))['demo-standard']; + const refusal = found.find((f) => f.code === 'no-matrix'); + assert.ok(refusal, `no refusal among: ${JSON.stringify(found)}`); + assert.equal(refusal.level, 'error'); + assert.equal(refusal.file, 'references/patterns.md'); + assert.match(refusal.message, + /grounding[\\/]standards[\\/]demo-standard[\\/]references[\\/]patterns\.md/); +}); + +test('a reference file graded by its own matrix passes, and its findings name it', async (t) => { + const repo = await withReference(t, REFERENCE); + const matrix = path.join(repo, 'grounding', 'standards', 'demo-standard', 'references'); + await fsp.mkdir(matrix, { recursive: true }); + await fsp.writeFile(path.join(matrix, 'patterns.md'), REFERENCE_MATRIX); + const found = (await checkAll(repo, { now: NOW }))['demo-standard']; + assert.deepEqual(errors(found), []); + // The coverage note is per matrix, so the reference file gets its own. + const notes = found.filter((f) => f.file === 'references/patterns.md'); + assert.deepEqual(notes.map((f) => f.code), ['audit-coverage', 'quote-coverage']); + assert.ok(found.some((f) => f.file === 'SKILL.md'), 'the skill keeps its own findings'); + + // The row space is separate, which is the whole reason for a second file. The + // reference file carries a heading `Rules` too, and the sentence under it is + // the sentence `SKILL.md` carries, so one shared space would let either + // matrix claim the other's occurrence. + await fsp.writeFile(path.join(matrix, 'patterns.md'), + REFERENCE_MATRIX.replace('| N-01 | Patterns | Patterns | | | Section title | |\n', '')); + const after = (await checkAll(repo, { now: NOW }))['demo-standard']; + assert.deepEqual(errors(after).map((f) => [f.file, f.code]), + [['references/patterns.md', 'uncovered-statement']]); +}); + +test('a matrix that grades no file the skill ships is refused', async (t) => { + // The mirror of the file with no matrix. A reference file renamed under its + // matrix leaves rows nothing opens, and no check here reads a file nobody + // names, so it would pass forever. + const repo = await withReference(t, REFERENCE); + const matrix = path.join(repo, 'grounding', 'standards', 'demo-standard', 'references'); + await fsp.mkdir(matrix, { recursive: true }); + await fsp.writeFile(path.join(matrix, 'patterns.md'), REFERENCE_MATRIX); + await fsp.writeFile(path.join(matrix, 'withdrawn.md'), REFERENCE_MATRIX); + const found = (await checkAll(repo, { now: NOW }))['demo-standard']; + const refusal = found.find((f) => f.code === 'matrix-grades-nothing'); + assert.ok(refusal, `no refusal among: ${JSON.stringify(found)}`); + assert.equal(refusal.level, 'error'); + assert.match(refusal.message, /withdrawn\.md/); +}); + +test('a reference file that is not a plain file is refused and never read', async (t) => { + // `readFile` resolves a link, so grading one would read bytes from wherever + // it points, and a FIFO at a graded path would hang the run rather than fail + // it. The refusal is the finding, and it fails the gate either way. + const repo = await withReference(t, REFERENCE); + const dir = path.join(repo, 'skills', 'standards', 'demo-standard', 'references'); + const outside = path.join(repo, 'outside.md'); + await fsp.writeFile(outside, '# Outside\n'); + await fsp.rm(path.join(dir, 'patterns.md')); + try { + await fsp.symlink(outside, path.join(dir, 'patterns.md')); + } catch { + return t.skip('this platform does not let the test create a symbolic link'); + } + const found = (await checkAll(repo, { now: NOW }))['demo-standard']; + assert.ok(found.some((f) => f.code === 'shipped-file-not-regular')); + assert.deepEqual(found.filter((f) => f.file === 'references/patterns.md'), []); + return undefined; +}); + +test('a reference file no Markdown walk can read is refused by name', async (t) => { + // The allowlist admits the directory. A matrix disposes of what the walk + // reads, and the walk reads Markdown, so a file of another kind there ships + // with nothing able to grade it. + const repo = await withReference(t, 'interface:\n', 'agents.yaml'); + const found = (await checkAll(repo, { now: NOW }))['demo-standard']; + const refusal = found.find((f) => f.code === 'reference-not-markdown'); + assert.ok(refusal, `no refusal among: ${JSON.stringify(found)}`); + assert.equal(refusal.level, 'error'); + assert.match(refusal.message, /^references\/agents\.yaml/); +}); + +test('every file the shipped catalogue grades has a matrix', async () => { + // Issue #99 in the shape it was reported: the two STE reference files ship on + // every install pathway, and no row disposed of a line in either. + const all = await checkAll(path.join(import.meta.dirname, '..'), { now: NOW }); + assert.deepEqual(Object.entries(all) + .flatMap(([name, found]) => found + .filter((f) => ['no-matrix', 'matrix-grades-nothing', 'reference-not-markdown'].includes(f.code)) + .map((f) => `${name}: ${f.message}`)), []); + const graded = all['simplified-technical-english'].map((f) => f.file); + for (const rel of ['SKILL.md', 'references/examples.md', 'references/rule-navigation.md']) { + assert.ok(graded.includes(rel), `${rel} was read`); + } +}); From c8642158889348149d560802e3d3864424aa15d4 Mon Sep 17 00:00:00 2001 From: logan Date: Fri, 14 Aug 2026 17:29:07 -0400 Subject: [PATCH 2/3] fix: a second graded file re-justifies what the first one was exempt from (#99) Review of #118 found five defects, and three of them are one root cause: the checker was written for exactly one graded file per skill, and three of its assumptions came along unexamined when a second file kind arrived. Front matter is metadata because a harness reads it, which is true of `SKILL.md` and of no reference file. A closed `---` block in one was removed from the units and reported by nothing, while `micromark` renders a thematic break and a setext heading carrying every line of it. It is refused now, in any subject but `SKILL.md`, and still removed from the units rather than graded, because reading three lines as prose would ground a paragraph no reader sees. `checkSkill` takes the file it grades as `subject`, with no default, so a caller that does not name the file cannot be handed the exemption. The grounding tree is not reachable from the catalogue. A matrix whose skill was deleted or renamed sits under a directory no catalogue entry names, so walking out from each skill never visited the one case the check exists for. The scan walks `grounding/` and derives the skill from the path, which also catches the leftover `/.md` one level up, and a stray is reported under the name its path implies. A path names a file only after `lstat` says so. Following a link at a matrix path lets two graded files share one physical audit record, or lets the check read a record from outside the tree, and the stray scan sees neither, because the link stands at exactly the pathname the scan holds. Each of those three was flipped off and watched to fail before the fix was restored, so the tests are evidence rather than decoration. The other two are classification. A heading is graded by what it says, and not by being a heading: `One instruction per sentence` states the constraint its section teaches, so an `N` row over it retired a rule from review by calling it a title. Seven headings in `examples.md` state a constraint, six citing the rule the skill's own matrix already cites, and the seventh carrying our authority because Issue 9 has no numbered pronoun rule. The seven contents entries that repeat those constraints are `E` rows, because under-claiming on a pointer is the safe direction. A table is one unit, so a table is one authority class. `rule-navigation.md` carried one table whose `Read when` column is our own advice, and a `G` row over that designator attributed every recommendation in it to Rules 1.1 through 9.4. A designator cannot be split, so the file was: it carries a table of source locations graded `G`, and a table of our advice graded `E`. The two matrices carry 127 rows now, twelve of them `G` rows, and every one still reads `unaudited` and `unquoted`. Nobody read the standard on this date. --- AGENTS.md | 22 ++ CHANGELOG.md | 25 +- CONTRIBUTING.md | 5 + README.md | 6 +- .../adr/0030-a-matrix-disposes-of-one-file.md | 56 ++++- .../references/examples.md | 232 +++++++++--------- .../references/rule-navigation.md | 33 ++- .../references/rule-navigation.md | 61 +++-- src/ground.js | 205 ++++++++++++---- test/gfm-render.test.js | 25 +- test/ground.test.js | 104 +++++++- 11 files changed, 556 insertions(+), 218 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cf70f15..a603baa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,16 @@ matrix is refused, and so is a matrix that grades no file. `references/` holds Markdown, because the walk reads Markdown alone and nothing can grade a file it cannot read. ADR-0030 records the decision. +Two things follow from identity being a PATH. The stray scan walks the whole +grounding tree and derives the skill from the path, rather than walking out from +each catalogue entry: a matrix whose skill was deleted or renamed sits under a +directory the catalogue cannot name, so starting from the catalogue never +visited the one case the check exists for. And a matrix is asked for with +`lstat` and refused unless it is a plain file, because following a link lets two +graded files share one audit record, or lets the check read a record from +outside the tree. Neither is visible to the other: the link sits at exactly the +pathname the scan holds. + - A **`G` row** claims the authority of the source. Its rule cell names the rule. - An **`E` row** is our own editorial guidance. Its rule cell is empty. - An **`N` row** is narrative. It orients the reader and asserts no rule, so it @@ -77,6 +87,18 @@ like any other section. Each of those was a hiding place: an instruction under a heading called `Source` was disposed of by nothing. Front matter is the one thing outside the check, because it is metadata for the harness. +That exemption belongs to `SKILL.md` and to no other file. The harness parses a +skill's front matter and never shows it to a writer, which is the whole warrant, +and no harness reads a reference file's prefix. A closed `---` block there was +removed from the units and reported by nothing, while a reader saw a thematic +break and a heading carrying every line of it, so a rule written there shipped +visible to the reader and invisible to the check. `checkSkill` takes the file it +is grading as `subject`, with no default, because a caller that does not say +which file it grades may not be handed the exemption. A block in any other file +is refused by name. The render is in `test/gfm-render.test.js`, by ADR-0028's +rule, and it decided the disposition: three lines a reader sees as a break and a +heading are not graded as prose nobody wrote. + Each row claims one occurrence. A skill that repeats a sentence needs a row for each time it says it. diff --git a/CHANGELOG.md b/CHANGELOG.md index f3e3262..a10233a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,14 +14,27 @@ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `grounding/standards/simplified-technical-english/references/examples.md`. The two STE reference files installed on every pathway with no row disposing of a line in either, and one of them mapped real `Rule N.N` identifiers to topic - labels with no `G` row anywhere. They carry 124 rows between them now, six of - them `G` rows, every one `unaudited` and `unquoted`. A row space is per file + labels with no `G` row anywhere. They carry 127 rows between them now, twelve + of them `G` rows, every one `unaudited` and `unquoted`. A row space is per file because `Our anchor` names a heading, and two files in one skill can carry the same heading. A graded file with no matrix is an error naming the matrix to - write, and so is a matrix that grades no file. A file under `references/` that - is not Markdown is refused, because the walk reads Markdown alone. Every - finding names the file it came from, beside the skill. ADR-0030 records the - decision, and it amends ADR-0025's count from a note to an error. Issue #99. + write, and so is a file under `grounding/` that grades no file any skill + ships. That scan walks the grounding tree and derives the skill from the path, + because a matrix whose skill was deleted or renamed sits under a directory the + catalogue cannot name. A matrix is asked for with `lstat` and refused unless + it is a plain file, because following a link there lets two graded files share + one audit record. A file under `references/` that is not Markdown is refused, + because the walk reads Markdown alone. A front matter block outside `SKILL.md` + is refused, because the harness that reads one is the whole warrant for the + exemption, and `checkSkill` takes the file it grades as `subject` with no + default. Every finding names the file it came from, beside the skill. + ADR-0030 records the decision, and it amends ADR-0025's count from a note to + an error. Issue #99. +- `skills/standards/simplified-technical-english/references/rule-navigation.md` + carries two tables where it carried one. A table is one unit, so a table is + one authority class, and the single table mixed source locations with our own + advice about when to read them. The first table states where the standard + answers each question, and the second is our advice. ADR-0030. - A blockquote is a unit the checker reads, rather than a construct it refuses. It is one block, from its first marker at column 0 to the first line without one, named by a designator such as `[quote 8f3a2b1c]` whose digest binds the diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b6f35b..021f8e8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -318,6 +318,11 @@ so does a matrix that grades no file. ADR-0030 gives the reason. A file under `references/` is Markdown. The walk reads Markdown alone, so nothing can grade a file of another kind, and the check refuses one by name. +Do not open a reference file with a front matter block. That exemption belongs +to `SKILL.md`, whose block a harness reads as metadata. Nothing reads a +reference file's block, so a rule written there would be graded by nothing, and +the check refuses it. + ## Write under the skills Our own documents follow ASD-STE100. `npm run lint:docs` checks them: diff --git a/README.md b/README.md index 75145b4..af3f5f4 100644 --- a/README.md +++ b/README.md @@ -379,8 +379,10 @@ skill fails the gate. ADR-0025 records that decision. `stylewright ground --check --all` fails when a skill changes and its matrix does not. Every heading, paragraph, list item, table and code block counts, -including the ones before the first heading. Front matter does not, because it -is metadata for the agent harness rather than instruction for a reader. +including the ones before the first heading. Front matter in `SKILL.md` does +not, because the harness reads it as metadata rather than as instruction for a +reader. No harness reads a reference file, so a front matter block in one is +refused instead. The check reads Markdown a line at a time, and it models no container. So it states the forms it reads: a blank line, any construct at column 0, a line that diff --git a/docs/adr/0030-a-matrix-disposes-of-one-file.md b/docs/adr/0030-a-matrix-disposes-of-one-file.md index 5499543..614a3ff 100644 --- a/docs/adr/0030-a-matrix-disposes-of-one-file.md +++ b/docs/adr/0030-a-matrix-disposes-of-one-file.md @@ -32,8 +32,8 @@ skills/standards/simplified-technical-english/references/examples.md grounding/standards/simplified-technical-english/references/examples.md ``` -A graded file with no matrix is an error, and so is a matrix under that -directory that grades no file the skill ships. +A graded file with no matrix is an error, and so is a file under `grounding/` +that grades no file any skill ships. ## Which file a row belongs to @@ -84,6 +84,36 @@ reads, and the walk reads nothing else, so a file of another kind there is refused by name. This is ADR-0025's allowlist one level down: the directory says what may ship, and this says what may stand inside it. +**Three assumptions the one-file checker carried, and what each becomes.** Every +one of them was justified by there being exactly one graded file per skill, and +review found all three at once. + +*Front matter is metadata.* True of `SKILL.md`, whose block the harness parses +and never shows a writer. No harness reads a reference file's prefix, so a +closed `---` block there was removed from the units and reported by nothing, +while `micromark` renders a thematic break and a setext heading carrying every +line of it. A rule written there shipped visible to the reader and invisible to +the check. The block is refused in any subject but `SKILL.md`, and it is still +removed from the units rather than graded, because reading three lines as prose +would ground a paragraph no reader sees. `checkSkill` takes `subject` with no +default, so a caller that does not name the file it grades cannot be handed the +exemption. That is the rule `now` obeys, for its reason. + +*The grounding tree is reachable from the catalogue.* It is not. A matrix whose +skill was deleted or renamed sits under a directory no catalogue entry names, so +walking out from each skill never visited it and the run stayed green over +exactly the stale record this ADR refuses. The scan walks `grounding/` and +derives the skill from the path, which also catches the same defect one level +up: a leftover `/.md`. A stray is reported under the name its path +implies, because the catalogue is what it fell out of. + +*A path names a file.* Only after `lstat` says so. A matrix is identified by its +path, so following a link there lets two graded files share one physical audit +record, or lets the check read a record from outside the grounding tree. The +stray scan cannot see either, because the link stands at exactly the pathname +the scan holds. This is the disposition the shipped-file allowlist already gives +a link at an allowed name. + ## The count was a note, and it is an error now ADR-0025 printed how many files under `references/` no row disposed of, as a @@ -96,9 +126,29 @@ matrix to write. That is the opposite of quieting the output. A green run over a file nobody has graded was the gap the number existed to report, and the gap is closed by refusing rather than by counting. ADR-0025 is amended to say so. +## A unit is graded by what it says + +Two rules were drawn while filling the column, and both are written into the +matrices that use them. + +A heading is graded by its words rather than by being a heading. `One +instruction per sentence` states the constraint its section teaches, and an `N` +row over it retires a rule from review by calling it a title. The test is +whether the heading says what a writer must do. Seven headings in `examples.md` +do, six of them citing the rule the skill's own matrix already cites for the +same claim, and the seventh carrying our authority because Issue 9 has no +numbered pronoun rule. The rest name a subject, such as `Procedure`. + +A table is one unit, so a table is one authority class. `rule-navigation.md` +carried one table whose `Read when` column is our own advice, and a `G` row over +that designator attributed every recommendation in it to Rules 1.1 through 9.4. +The designator cannot be split, so the FILE was: it carries a table of source +locations graded `G`, and a table of our advice graded `E`. The questions repeat +across both, which is the cost, and no row mixes the two. + ## What this does not claim -Every row these matrices add starts `unaudited` and `unquoted`. Six `G` rows +Every row these matrices add starts `unaudited` and `unquoted`. Twelve `G` rows across the two files cite a rule, and nobody has read one of them against the standard. The matrices declare `**Quotation:** forbidden` for the reason the skill's own matrix does, so no rule text moves into a cell. diff --git a/grounding/standards/simplified-technical-english/references/examples.md b/grounding/standards/simplified-technical-english/references/examples.md index 2fc1b84..7121ce3 100644 --- a/grounding/standards/simplified-technical-english/references/examples.md +++ b/grounding/standards/simplified-technical-english/references/examples.md @@ -22,6 +22,18 @@ limit, is a `G` row, and it cites what the skill's own matrix cites for the same claim. A unit that says what OUR example does is an `E` row, because it carries our authority and not the standard's. +A heading is graded by what it says, and not by being a heading. `One +instruction per sentence` states the constraint its section teaches, so an `N` +row over it would retire a rule from review by calling it a title. The test is +whether the heading says what a writer must do: seven headings here do, and the +rest name a subject, such as `Procedure` or `Compliance boundary`. Six of the +seven cite the rule the skill's own matrix cites, and `Unambiguous pronouns` +carries our authority because Issue 9 has no numbered pronoun rule. + +A contents entry repeating one of those constraints is an `E` row. It points at +the section that carries the citation, and under-claiming on a pointer is the +safe direction. + Every quoted example is an `E` row. The sentences inside a `Before` block are imperative, and they are the text to revise rather than an instruction to the reader. An `N` row over them would retire a directive from review by calling it @@ -47,113 +59,113 @@ the skill moves to a new issue, and every audit here goes stale at once. | N-01 | Simplified Technical English Revision Patterns | Simplified Technical English Revision Patterns | | | Section title, asserts no rule | | | E-01 | These are original illustrative examples. They show revision patterns but do not reproduce the official standard or establish strict compliance. | Simplified Technical English Revision Patterns | | | Our boundary statement | | | N-02 | Contents | Contents | | | Section title, asserts no rule | | -| N-03 | [One instruction per sentence](#one-instruction-per-sentence) | Contents | | | Navigation, asserts no rule | | -| N-04 | [Condition before command](#condition-before-command) | Contents | | | Navigation, asserts no rule | | -| N-05 | [Active voice and a named actor](#active-voice-and-a-named-actor) | Contents | | | Navigation, asserts no rule | | -| N-06 | [Action verbs instead of abstract nouns](#action-verbs-instead-of-abstract-nouns) | Contents | | | Navigation, asserts no rule | | -| N-07 | [Shorter multi-word nouns](#shorter-multi-word-nouns) | Contents | | | Navigation, asserts no rule | | -| N-08 | [One term for one item](#one-term-for-one-item) | Contents | | | Navigation, asserts no rule | | -| N-09 | [Unambiguous pronouns](#unambiguous-pronouns) | Contents | | | Navigation, asserts no rule | | -| N-10 | [Procedure and description limits](#procedure-and-description-limits) | Contents | | | Navigation, asserts no rule | | -| N-11 | [Warning and caution](#warning-and-caution) | Contents | | | Navigation, asserts no rule | | -| N-12 | [Vertical lists and connecting words](#vertical-lists-and-connecting-words) | Contents | | | Navigation, asserts no rule | | -| N-13 | One instruction per sentence | One instruction per sentence | | | Section title, asserts no rule | | -| N-14 | **Before** | One instruction per sentence | | | Label over an example, asserts no rule | | -| E-02 | [quote 014465f0] | One instruction per sentence | | | Our own example of the text to revise | | -| N-15 | **After** | One instruction per sentence | | | Label over an example, asserts no rule | | -| E-03 | [quote f89021e9] | One instruction per sentence | | | Our own revision, written to show the pattern | | -| N-16 | **Why** | One instruction per sentence | | | Label over an example, asserts no rule | | -| E-04 | Each sentence gives one instruction. The condition applies only to replacement, and repetition removes pronoun ambiguity. | One instruction per sentence | | | Our own explanation of the example | | -| N-17 | Condition before command | Condition before command | | | Section title, asserts no rule | | -| N-18 | **Before** | Condition before command | | | Label over an example, asserts no rule | | -| E-05 | [quote 61041583] | Condition before command | | | Our own example of the text to revise | | -| N-19 | **After** | Condition before command | | | Label over an example, asserts no rule | | -| E-06 | [quote b8e95e15] | Condition before command | | | Our own revision, written to show the pattern | | -| N-20 | **Why** | Condition before command | | | Label over an example, asserts no rule | | -| E-07 | The reader receives the necessary condition before the command. The value and unit do not change. | Condition before command | | | Our own explanation of the example | | -| N-21 | Active voice and a named actor | Active voice and a named actor | | | Section title, asserts no rule | | -| N-22 | **Before** | Active voice and a named actor | | | Label over an example, asserts no rule | | -| E-08 | [quote 590e706b] | Active voice and a named actor | | | Our own example of the text to revise | | -| N-23 | **After** | Active voice and a named actor | | | Label over an example, asserts no rule | | -| E-09 | [quote ef8234bb] | Active voice and a named actor | | | Our own revision, written to show the pattern | | -| N-24 | **Why** | Active voice and a named actor | | | Label over an example, asserts no rule | | -| E-10 | The revision names the actor for each action. Use the approved role name from the applicable procedure. | Active voice and a named actor | | | Our own explanation of the example | | -| N-25 | Action verbs instead of abstract nouns | Action verbs instead of abstract nouns | | | Section title, asserts no rule | | -| N-26 | **Before** | Action verbs instead of abstract nouns | | | Label over an example, asserts no rule | | -| E-11 | [quote df1b50d6] | Action verbs instead of abstract nouns | | | Our own example of the text to revise | | -| N-27 | **After** | Action verbs instead of abstract nouns | | | Label over an example, asserts no rule | | -| E-12 | [quote 6f291517] | Action verbs instead of abstract nouns | | | Our own revision, written to show the pattern | | -| N-28 | **Why** | Action verbs instead of abstract nouns | | | Label over an example, asserts no rule | | -| E-13 | The verb states the action directly. The revision removes an unnecessary noun phrase without changing the object of the examination. | Action verbs instead of abstract nouns | | | Our own explanation of the example | | -| N-29 | Shorter multi-word nouns | Shorter multi-word nouns | | | Section title, asserts no rule | | -| N-30 | **Before** | Shorter multi-word nouns | | | Label over an example, asserts no rule | | -| E-14 | [quote 343b3892] | Shorter multi-word nouns | | | Our own example of the text to revise | | -| N-31 | **After** | Shorter multi-word nouns | | | Label over an example, asserts no rule | | -| E-15 | [quote 95088bca] | Shorter multi-word nouns | | | Our own revision, written to show the pattern | | -| N-32 | **Why** | Shorter multi-word nouns | | | Label over an example, asserts no rule | | -| E-16 | Prepositional phrases separate the relationships in the noun stack. Confirm that the revised relationships match the product data. | Shorter multi-word nouns | | | Our own explanation of the example | | -| G-01 | When a long official term cannot change, introduce it before a short form: | Shorter multi-word nouns | Rule 2.2, Rule 8.2 | unquoted | Part 1, Sections 2 and 8 | unaudited | -| E-17 | [quote 1943e8d5] | Shorter multi-word nouns | | | Our own revision, written to show the pattern | | -| N-33 | One term for one item | One term for one item | | | Section title, asserts no rule | | -| N-34 | **Before** | One term for one item | | | Label over an example, asserts no rule | | -| E-18 | [quote 191b70a9] | One term for one item | | | Our own example of the text to revise | | -| N-35 | **After** | One term for one item | | | Label over an example, asserts no rule | | -| E-19 | [quote c26fc750] | One term for one item | | | Our own revision, written to show the pattern | | -| N-36 | **Why** | One term for one item | | | Label over an example, asserts no rule | | -| G-02 | One item has one name. Repetition is preferable to synonyms when the synonyms can suggest different parts. | One term for one item | Rule 1.11 | unquoted | Part 1, Section 1 | unaudited | -| N-37 | Unambiguous pronouns | Unambiguous pronouns | | | Section title, asserts no rule | | -| N-38 | **Before** | Unambiguous pronouns | | | Label over an example, asserts no rule | | -| E-20 | [quote ce71b8c2] | Unambiguous pronouns | | | Our own example of the text to revise | | -| N-39 | **After — examine the hose** | Unambiguous pronouns | | | Label over an example, asserts no rule | | -| E-21 | [quote ca8fe21a] | Unambiguous pronouns | | | Our own revision, written to show the pattern | | -| N-40 | **After — examine the pump** | Unambiguous pronouns | | | Label over an example, asserts no rule | | -| E-22 | [quote 232f0f36] | Unambiguous pronouns | | | Our own revision, written to show the pattern | | -| N-41 | **Why** | Unambiguous pronouns | | | Label over an example, asserts no rule | | -| E-23 | The source sentence does not identify the object of `it`. Select the applicable revision only after the technical intent is known. | Unambiguous pronouns | | | Our own explanation of the example | | -| N-42 | Procedure and description limits | Procedure and description limits | | | Section title, asserts no rule | | -| N-43 | Procedure | Procedure | | | Section title, asserts no rule | | -| N-44 | **Before** | Procedure | | | Label over an example, asserts no rule | | -| E-24 | [quote 8544acde] | Procedure | | | Our own example of the text to revise | | -| N-45 | **After** | Procedure | | | Label over an example, asserts no rule | | -| E-25 | [quote 67812f21] | Procedure | | | Our own revision, written to show the pattern | | -| N-46 | **Why** | Procedure | | | Label over an example, asserts no rule | | -| G-03 | The revision uses short imperative sentences and keeps one instruction in each sentence. Count a procedural sentence against the 20-word limit. | Procedure | Rule 5.1, Rule 5.2, Rule 5.3 | unquoted | Part 1, Section 5 | unaudited | -| N-47 | Description | Description | | | Section title, asserts no rule | | -| N-48 | **Before** | Description | | | Label over an example, asserts no rule | | -| E-26 | [quote f29a84a7] | Description | | | Our own example of the text to revise | | -| N-49 | **After** | Description | | | Label over an example, asserts no rule | | -| E-27 | [quote 4c8c1153] | Description | | | Our own revision, written to show the pattern | | -| N-50 | **Why** | Description | | | Label over an example, asserts no rule | | -| G-04 | The revision gives information gradually and separates related ideas. Count each descriptive sentence against the 25-word limit. | Description | Rule 6.1, Rule 6.3 | unquoted | Part 1, Section 6 | unaudited | -| N-51 | Warning and caution | Warning and caution | | | Section title, asserts no rule | | -| N-52 | Injury or death risk | Injury or death risk | | | Section title, asserts no rule | | -| N-53 | **Before** | Injury or death risk | | | Label over an example, asserts no rule | | -| E-28 | [quote c7b88fb3] | Injury or death risk | | | Our own example of the text to revise | | -| N-54 | **After** | Injury or death risk | | | Label over an example, asserts no rule | | -| E-29 | [quote 675c1348] | Injury or death risk | | | Our own revision, written to show the pattern | | -| N-55 | **Why** | Injury or death risk | | | Label over an example, asserts no rule | | -| G-05 | In ASD usage, an injury or death risk requires a warning, not a caution. Confirm the approved signal word, command, hazard, and consequence with the applicable safety source. | Injury or death risk | Rule 7.1, Rule 7.2, Rule 7.3 | unquoted | Part 1, Section 7 | unaudited | -| N-56 | Object-damage risk | Object-damage risk | | | Section title, asserts no rule | | -| N-57 | **Before** | Object-damage risk | | | Label over an example, asserts no rule | | -| E-30 | [quote 2461b2c2] | Object-damage risk | | | Our own example of the text to revise | | -| N-58 | **After** | Object-damage risk | | | Label over an example, asserts no rule | | -| E-31 | [quote bb3e9d7e] | Object-damage risk | | | Our own revision, written to show the pattern | | -| N-59 | **Why** | Object-damage risk | | | Label over an example, asserts no rule | | -| E-32 | The stated consequence is damage to an object. Confirm that no injury risk is omitted before selecting a caution. | Object-damage risk | | | Our own explanation of the example | | -| N-60 | Vertical lists and connecting words | Vertical lists and connecting words | | | Section title, asserts no rule | | -| N-61 | Vertical list | Vertical list | | | Section title, asserts no rule | | -| N-62 | **Before** | Vertical list | | | Label over an example, asserts no rule | | -| E-33 | [quote b08055ef] | Vertical list | | | Our own example of the text to revise | | -| N-63 | **After** | Vertical list | | | Label over an example, asserts no rule | | -| E-34 | [quote d163ca87] | Vertical list | | | Our own revision, written to show the pattern | | -| N-64 | **Why** | Vertical list | | | Label over an example, asserts no rule | | -| E-35 | The list makes the quantities easy to check. Apply the official punctuation and word-count rules before release. | Vertical list | | | Our own explanation of the example | | -| N-65 | Connecting words | Connecting words | | | Section title, asserts no rule | | -| N-66 | **Before** | Connecting words | | | Label over an example, asserts no rule | | -| E-36 | [quote 67961009] | Connecting words | | | Our own example of the text to revise | | -| N-67 | **After** | Connecting words | | | Label over an example, asserts no rule | | -| E-37 | [quote 96265fe9] | Connecting words | | | Our own revision, written to show the pattern | | -| N-68 | **Why** | Connecting words | | | Label over an example, asserts no rule | | -| E-38 | The connecting word states the relationship. Use it only when the cause-and-effect relation is verified. | Connecting words | | | Our own explanation of the example | | -| N-69 | Compliance boundary | Compliance boundary | | | Section title, asserts no rule | | -| E-39 | These examples are patterns for revision and explanation. For strict compliance, check the applicable writing rules, every general word in the official dictionary, and every technical term in the applicable terminology database. | Compliance boundary | | | Our boundary statement | | +| E-02 | [One instruction per sentence](#one-instruction-per-sentence) | Contents | | | Navigation, and it repeats the constraint of its section | | +| E-03 | [Condition before command](#condition-before-command) | Contents | | | Navigation, and it repeats the constraint of its section | | +| E-04 | [Active voice and a named actor](#active-voice-and-a-named-actor) | Contents | | | Navigation, and it repeats the constraint of its section | | +| E-05 | [Action verbs instead of abstract nouns](#action-verbs-instead-of-abstract-nouns) | Contents | | | Navigation, and it repeats the constraint of its section | | +| E-06 | [Shorter multi-word nouns](#shorter-multi-word-nouns) | Contents | | | Navigation, and it repeats the constraint of its section | | +| E-07 | [One term for one item](#one-term-for-one-item) | Contents | | | Navigation, and it repeats the constraint of its section | | +| E-08 | [Unambiguous pronouns](#unambiguous-pronouns) | Contents | | | Navigation, and it repeats the constraint of its section | | +| N-03 | [Procedure and description limits](#procedure-and-description-limits) | Contents | | | Navigation, asserts no rule | | +| N-04 | [Warning and caution](#warning-and-caution) | Contents | | | Navigation, asserts no rule | | +| N-05 | [Vertical lists and connecting words](#vertical-lists-and-connecting-words) | Contents | | | Navigation, asserts no rule | | +| G-01 | One instruction per sentence | One instruction per sentence | Rule 5.2 | unquoted | Part 1, Section 5 | unaudited | +| N-06 | **Before** | One instruction per sentence | | | Label over an example, asserts no rule | | +| E-09 | [quote 014465f0] | One instruction per sentence | | | Our own example of the text to revise | | +| N-07 | **After** | One instruction per sentence | | | Label over an example, asserts no rule | | +| E-10 | [quote f89021e9] | One instruction per sentence | | | Our own revision, written to show the pattern | | +| N-08 | **Why** | One instruction per sentence | | | Label over an example, asserts no rule | | +| E-11 | Each sentence gives one instruction. The condition applies only to replacement, and repetition removes pronoun ambiguity. | One instruction per sentence | | | Our own explanation of the example | | +| G-02 | Condition before command | Condition before command | Rule 5.4 | unquoted | Part 1, Section 5 | unaudited | +| N-09 | **Before** | Condition before command | | | Label over an example, asserts no rule | | +| E-12 | [quote 61041583] | Condition before command | | | Our own example of the text to revise | | +| N-10 | **After** | Condition before command | | | Label over an example, asserts no rule | | +| E-13 | [quote b8e95e15] | Condition before command | | | Our own revision, written to show the pattern | | +| N-11 | **Why** | Condition before command | | | Label over an example, asserts no rule | | +| E-14 | The reader receives the necessary condition before the command. The value and unit do not change. | Condition before command | | | Our own explanation of the example | | +| G-03 | Active voice and a named actor | Active voice and a named actor | Rule 3.6 | unquoted | Part 1, Section 3 | unaudited | +| N-12 | **Before** | Active voice and a named actor | | | Label over an example, asserts no rule | | +| E-15 | [quote 590e706b] | Active voice and a named actor | | | Our own example of the text to revise | | +| N-13 | **After** | Active voice and a named actor | | | Label over an example, asserts no rule | | +| E-16 | [quote ef8234bb] | Active voice and a named actor | | | Our own revision, written to show the pattern | | +| N-14 | **Why** | Active voice and a named actor | | | Label over an example, asserts no rule | | +| E-17 | The revision names the actor for each action. Use the approved role name from the applicable procedure. | Active voice and a named actor | | | Our own explanation of the example | | +| G-04 | Action verbs instead of abstract nouns | Action verbs instead of abstract nouns | Rule 3.7 | unquoted | Part 1, Section 3 | unaudited | +| N-15 | **Before** | Action verbs instead of abstract nouns | | | Label over an example, asserts no rule | | +| E-18 | [quote df1b50d6] | Action verbs instead of abstract nouns | | | Our own example of the text to revise | | +| N-16 | **After** | Action verbs instead of abstract nouns | | | Label over an example, asserts no rule | | +| E-19 | [quote 6f291517] | Action verbs instead of abstract nouns | | | Our own revision, written to show the pattern | | +| N-17 | **Why** | Action verbs instead of abstract nouns | | | Label over an example, asserts no rule | | +| E-20 | The verb states the action directly. The revision removes an unnecessary noun phrase without changing the object of the examination. | Action verbs instead of abstract nouns | | | Our own explanation of the example | | +| G-05 | Shorter multi-word nouns | Shorter multi-word nouns | Rule 2.1, Rule 2.2 | unquoted | Part 1, Section 2 | unaudited | +| N-18 | **Before** | Shorter multi-word nouns | | | Label over an example, asserts no rule | | +| E-21 | [quote 343b3892] | Shorter multi-word nouns | | | Our own example of the text to revise | | +| N-19 | **After** | Shorter multi-word nouns | | | Label over an example, asserts no rule | | +| E-22 | [quote 95088bca] | Shorter multi-word nouns | | | Our own revision, written to show the pattern | | +| N-20 | **Why** | Shorter multi-word nouns | | | Label over an example, asserts no rule | | +| E-23 | Prepositional phrases separate the relationships in the noun stack. Confirm that the revised relationships match the product data. | Shorter multi-word nouns | | | Our own explanation of the example | | +| G-06 | When a long official term cannot change, introduce it before a short form: | Shorter multi-word nouns | Rule 2.2, Rule 8.2 | unquoted | Part 1, Sections 2 and 8 | unaudited | +| E-24 | [quote 1943e8d5] | Shorter multi-word nouns | | | Our own revision, written to show the pattern | | +| G-07 | One term for one item | One term for one item | Rule 1.11 | unquoted | Part 1, Section 1 | unaudited | +| N-21 | **Before** | One term for one item | | | Label over an example, asserts no rule | | +| E-25 | [quote 191b70a9] | One term for one item | | | Our own example of the text to revise | | +| N-22 | **After** | One term for one item | | | Label over an example, asserts no rule | | +| E-26 | [quote c26fc750] | One term for one item | | | Our own revision, written to show the pattern | | +| N-23 | **Why** | One term for one item | | | Label over an example, asserts no rule | | +| G-08 | One item has one name. Repetition is preferable to synonyms when the synonyms can suggest different parts. | One term for one item | Rule 1.11 | unquoted | Part 1, Section 1 | unaudited | +| E-27 | Unambiguous pronouns | Unambiguous pronouns | | | Our editorial check. Issue 9 has no numbered pronoun rule | | +| N-24 | **Before** | Unambiguous pronouns | | | Label over an example, asserts no rule | | +| E-28 | [quote ce71b8c2] | Unambiguous pronouns | | | Our own example of the text to revise | | +| N-25 | **After — examine the hose** | Unambiguous pronouns | | | Label over an example, asserts no rule | | +| E-29 | [quote ca8fe21a] | Unambiguous pronouns | | | Our own revision, written to show the pattern | | +| N-26 | **After — examine the pump** | Unambiguous pronouns | | | Label over an example, asserts no rule | | +| E-30 | [quote 232f0f36] | Unambiguous pronouns | | | Our own revision, written to show the pattern | | +| N-27 | **Why** | Unambiguous pronouns | | | Label over an example, asserts no rule | | +| E-31 | The source sentence does not identify the object of `it`. Select the applicable revision only after the technical intent is known. | Unambiguous pronouns | | | Our own explanation of the example | | +| N-28 | Procedure and description limits | Procedure and description limits | | | Section title, asserts no rule | | +| N-29 | Procedure | Procedure | | | Section title, asserts no rule | | +| N-30 | **Before** | Procedure | | | Label over an example, asserts no rule | | +| E-32 | [quote 8544acde] | Procedure | | | Our own example of the text to revise | | +| N-31 | **After** | Procedure | | | Label over an example, asserts no rule | | +| E-33 | [quote 67812f21] | Procedure | | | Our own revision, written to show the pattern | | +| N-32 | **Why** | Procedure | | | Label over an example, asserts no rule | | +| G-09 | The revision uses short imperative sentences and keeps one instruction in each sentence. Count a procedural sentence against the 20-word limit. | Procedure | Rule 5.1, Rule 5.2, Rule 5.3 | unquoted | Part 1, Section 5 | unaudited | +| N-33 | Description | Description | | | Section title, asserts no rule | | +| N-34 | **Before** | Description | | | Label over an example, asserts no rule | | +| E-34 | [quote f29a84a7] | Description | | | Our own example of the text to revise | | +| N-35 | **After** | Description | | | Label over an example, asserts no rule | | +| E-35 | [quote 4c8c1153] | Description | | | Our own revision, written to show the pattern | | +| N-36 | **Why** | Description | | | Label over an example, asserts no rule | | +| G-10 | The revision gives information gradually and separates related ideas. Count each descriptive sentence against the 25-word limit. | Description | Rule 6.1, Rule 6.3 | unquoted | Part 1, Section 6 | unaudited | +| N-37 | Warning and caution | Warning and caution | | | Section title, asserts no rule | | +| N-38 | Injury or death risk | Injury or death risk | | | Section title, asserts no rule | | +| N-39 | **Before** | Injury or death risk | | | Label over an example, asserts no rule | | +| E-36 | [quote c7b88fb3] | Injury or death risk | | | Our own example of the text to revise | | +| N-40 | **After** | Injury or death risk | | | Label over an example, asserts no rule | | +| E-37 | [quote 675c1348] | Injury or death risk | | | Our own revision, written to show the pattern | | +| N-41 | **Why** | Injury or death risk | | | Label over an example, asserts no rule | | +| G-11 | In ASD usage, an injury or death risk requires a warning, not a caution. Confirm the approved signal word, command, hazard, and consequence with the applicable safety source. | Injury or death risk | Rule 7.1, Rule 7.2, Rule 7.3 | unquoted | Part 1, Section 7 | unaudited | +| N-42 | Object-damage risk | Object-damage risk | | | Section title, asserts no rule | | +| N-43 | **Before** | Object-damage risk | | | Label over an example, asserts no rule | | +| E-38 | [quote 2461b2c2] | Object-damage risk | | | Our own example of the text to revise | | +| N-44 | **After** | Object-damage risk | | | Label over an example, asserts no rule | | +| E-39 | [quote bb3e9d7e] | Object-damage risk | | | Our own revision, written to show the pattern | | +| N-45 | **Why** | Object-damage risk | | | Label over an example, asserts no rule | | +| E-40 | The stated consequence is damage to an object. Confirm that no injury risk is omitted before selecting a caution. | Object-damage risk | | | Our own explanation of the example | | +| N-46 | Vertical lists and connecting words | Vertical lists and connecting words | | | Section title, asserts no rule | | +| N-47 | Vertical list | Vertical list | | | Section title, asserts no rule | | +| N-48 | **Before** | Vertical list | | | Label over an example, asserts no rule | | +| E-41 | [quote b08055ef] | Vertical list | | | Our own example of the text to revise | | +| N-49 | **After** | Vertical list | | | Label over an example, asserts no rule | | +| E-42 | [quote d163ca87] | Vertical list | | | Our own revision, written to show the pattern | | +| N-50 | **Why** | Vertical list | | | Label over an example, asserts no rule | | +| E-43 | The list makes the quantities easy to check. Apply the official punctuation and word-count rules before release. | Vertical list | | | Our own explanation of the example | | +| N-51 | Connecting words | Connecting words | | | Section title, asserts no rule | | +| N-52 | **Before** | Connecting words | | | Label over an example, asserts no rule | | +| E-44 | [quote 67961009] | Connecting words | | | Our own example of the text to revise | | +| N-53 | **After** | Connecting words | | | Label over an example, asserts no rule | | +| E-45 | [quote 96265fe9] | Connecting words | | | Our own revision, written to show the pattern | | +| N-54 | **Why** | Connecting words | | | Label over an example, asserts no rule | | +| E-46 | The connecting word states the relationship. Use it only when the cause-and-effect relation is verified. | Connecting words | | | Our own explanation of the example | | +| N-55 | Compliance boundary | Compliance boundary | | | Section title, asserts no rule | | +| E-47 | These examples are patterns for revision and explanation. For strict compliance, check the applicable writing rules, every general word in the official dictionary, and every technical term in the applicable terminology database. | Compliance boundary | | | Our boundary statement | | diff --git a/grounding/standards/simplified-technical-english/references/rule-navigation.md b/grounding/standards/simplified-technical-english/references/rule-navigation.md index f99115b..4799716 100644 --- a/grounding/standards/simplified-technical-english/references/rule-navigation.md +++ b/grounding/standards/simplified-technical-english/references/rule-navigation.md @@ -16,10 +16,16 @@ One matrix disposes of one file. The matrix for the skill itself is doctrine these rows follow. ADR-0030 records why a second graded file gets a second matrix rather than more rows in the first. -The table is one unit and one `G` row. It claims where each topic lives in the -standard, and a claim about the source is what a `G` row is for. Its rule cell -names the whole range the table covers, because the table covers a range. The -row cites the reading recorded in +A table is one unit, so a table is one authority class. The file carried one +table whose `Read when` column is our own advice, and a `G` row over that +designator attributed every recommendation in it to Rules 1.1 through 9.4. The +file now carries two tables. The first states where the standard answers each +question, and it is the `G` row. The second is our advice about when to read +there, and it is an `E` row. The questions repeat across both, which is the +cost of splitting them, and no row mixes the two. + +The `G` row's rule cell names the whole range its table covers, because the +table covers a range. The row cites the reading recorded in `source/standards/simplified-technical-english.md`, and its `Audited` cell says that nobody has checked one of those locations against the standard. @@ -45,11 +51,14 @@ the skill moves to a new issue, and every audit here goes stale at once. | N-01 | ASD-STE100 Issue 9 Rule Navigation | ASD-STE100 Issue 9 Rule Navigation | | | Section title, asserts no rule | | | E-01 | Use this map to find relevant material in the official standard. The topic labels below are paraphrases, not rule text. Search the [official Issue 9 PDF](https://www.asd-ste100.org/assets/files/ASD-STE100_ISSUE9.pdf) by rule identifier and confirm the complete rule, explanation, and applicable dictionary entries there. | ASD-STE100 Issue 9 Rule Navigation | | | Our own routing instruction, not a rule of the standard | | | N-02 | Writing-rule map | Writing-rule map | | | Section title, asserts no rule | | -| G-01 | [table 5f581720] | Writing-rule map | Rules 1.1 through 9.4, and Part 2 | unquoted | Part 1, Sections 1 to 9, and Part 2 | unaudited | -| N-03 | Source-use boundary | Source-use boundary | | | Section title, asserts no rule | | -| E-02 | This navigator does not establish compliance. For strict compliance: | Source-use boundary | | | Our boundary statement | | -| E-03 | Open the official Issue 9 PDF. | Source-use boundary | | | Our own routing instruction, not a rule of the standard | | -| E-04 | Read each applicable rule and its explanation in full. | Source-use boundary | | | Our own routing instruction, not a rule of the standard | | -| E-05 | Check every general word in the controlled dictionary. | Source-use boundary | | | Our own routing instruction, not a rule of the standard | | -| E-06 | Check every technical noun and verb in the applicable terminology database. | Source-use boundary | | | Our own routing instruction, not a rule of the standard | | -| E-07 | Preserve product data, approved safety language, units, identifiers, labels, and quotations. | Source-use boundary | | | Our editing-safety check, not a writing rule | | +| N-03 | The first table states where the standard answers each question. The second table is our own advice about when to read there. The questions repeat across both, so each table carries one kind of claim and no row mixes the two. | Writing-rule map | | | Describes how this file is laid out, asserts no rule | | +| G-01 | [table 42e8c66c] | Writing-rule map | Rules 1.1 through 9.4, and Part 2 | unquoted | Part 1, Sections 1 to 9, and Part 2 | unaudited | +| N-04 | When to read there | When to read there | | | Section title, asserts no rule | | +| E-02 | [table 11979737] | When to read there | | | Our own advice about when to open the standard | | +| N-05 | Source-use boundary | Source-use boundary | | | Section title, asserts no rule | | +| E-03 | This navigator does not establish compliance. For strict compliance: | Source-use boundary | | | Our boundary statement | | +| E-04 | Open the official Issue 9 PDF. | Source-use boundary | | | Our own routing instruction, not a rule of the standard | | +| E-05 | Read each applicable rule and its explanation in full. | Source-use boundary | | | Our own routing instruction, not a rule of the standard | | +| E-06 | Check every general word in the controlled dictionary. | Source-use boundary | | | Our own routing instruction, not a rule of the standard | | +| E-07 | Check every technical noun and verb in the applicable terminology database. | Source-use boundary | | | Our own routing instruction, not a rule of the standard | | +| E-08 | Preserve product data, approved safety language, units, identifiers, labels, and quotations. | Source-use boundary | | | Our editing-safety check, not a writing rule | | diff --git a/skills/standards/simplified-technical-english/references/rule-navigation.md b/skills/standards/simplified-technical-english/references/rule-navigation.md index 93fca08..4e46ea3 100644 --- a/skills/standards/simplified-technical-english/references/rule-navigation.md +++ b/skills/standards/simplified-technical-english/references/rule-navigation.md @@ -4,24 +4,49 @@ Use this map to find relevant material in the official standard. The topic label ## Writing-rule map -| Question | Official location | Search in the PDF | Read when | -|---|---|---|---| -| Is a general word approved for this use? | Part 1, Section 1, Rules 1.1–1.4 | `Rule 1.1`, `Rule 1.2`, `Rule 1.3`, `Rule 1.4` | A word's approval, meaning, part of speech, or form is uncertain. | -| Can this domain term be used as a technical noun? | Part 1, Section 1, Rules 1.5–1.11 | `Rule 1.5` through `Rule 1.11` | Selecting, shortening, approving, or consistently reusing a technical noun. | -| Can this domain action be used as a technical verb? | Part 1, Section 1, Rules 1.12–1.13 | `Rule 1.12`, `Rule 1.13` | A necessary action is absent from the controlled dictionary or its noun/verb role is uncertain. | -| Which spelling convention applies? | Part 1, Section 1, Rule 1.14 | `Rule 1.14` | Choosing between American English and another officially required spelling. | -| Is a noun stack too long or difficult to parse? | Part 1, Section 2, Rules 2.1–2.2 | `Rule 2.1`, `Rule 2.2` | Revising a multi-word noun or introducing a long official term. | -| Are the verb form and tense permitted? | Part 1, Section 3, Rules 3.1–3.5 | `Rule 3.1` through `Rule 3.5` | Checking tense, participles, auxiliaries, or an `-ing` form. | -| Is active or passive voice appropriate? | Part 1, Section 3, Rule 3.6 | `Rule 3.6` | The actor is missing or a description uses passive voice. | -| Does the sentence use a direct action verb? | Part 1, Section 3, Rule 3.7 | `Rule 3.7` | An abstract noun phrase hides the action. | -| Is the sentence complete, clear, and connected? | Part 1, Section 4, Rules 4.1–4.5 | `Rule 4.1` through `Rule 4.5` | Checking omitted words, contractions, lists, connectors, or articles. | -| Does a procedure meet its structural limits? | Part 1, Section 5, Rules 5.1–5.5 | `Rule 5.1` through `Rule 5.5` | Checking sentence length, one instruction, imperative form, a preceding condition, or a note. | -| Does a description present information gradually? | Part 1, Section 6, Rules 6.1–6.6 | `Rule 6.1` through `Rule 6.6` | Checking sentence length, paragraph topic, paragraph length, keywords, or information order. | -| Is the safety signal and consequence correct? | Part 1, Section 7, Rules 7.1–7.3 | `Rule 7.1`, `Rule 7.2`, `Rule 7.3` | Selecting a warning or caution and stating the command, hazard, and possible result. | -| Is punctuation or word counting uncertain? | Part 1, Section 8, Rules 8.1–8.7 | `Rule 8.1` through `Rule 8.7` | Checking semicolons, hyphens, parentheses, lists, identifiers, units, quoted text, or word counts. | -| Is literal substitution creating unclear text? | Part 1, Section 9, Rule 9.1 | `Rule 9.1` | A word-for-word replacement is grammatical but difficult to understand. | -| Is wording correct and consistent? | Part 1, Section 9, Rules 9.2–9.4 | `Rule 9.2`, `Rule 9.3`, `Rule 9.4` | Checking approved-word usage, phrasal verbs, terminology, or style consistency. | -| Is strict vocabulary compliance required? | Part 2, Dictionary | `Part 2`, then the candidate word | Every general word must be checked against its entry and approved use. | +The first table states where the standard answers each question. The second +table is our own advice about when to read there. The questions repeat across +both, so each table carries one kind of claim and no row mixes the two. + +| Question | Official location | Search in the PDF | +|---|---|---| +| Is a general word approved for this use? | Part 1, Section 1, Rules 1.1–1.4 | `Rule 1.1`, `Rule 1.2`, `Rule 1.3`, `Rule 1.4` | +| Can this domain term be used as a technical noun? | Part 1, Section 1, Rules 1.5–1.11 | `Rule 1.5` through `Rule 1.11` | +| Can this domain action be used as a technical verb? | Part 1, Section 1, Rules 1.12–1.13 | `Rule 1.12`, `Rule 1.13` | +| Which spelling convention applies? | Part 1, Section 1, Rule 1.14 | `Rule 1.14` | +| Is a noun stack too long or difficult to parse? | Part 1, Section 2, Rules 2.1–2.2 | `Rule 2.1`, `Rule 2.2` | +| Are the verb form and tense permitted? | Part 1, Section 3, Rules 3.1–3.5 | `Rule 3.1` through `Rule 3.5` | +| Is active or passive voice appropriate? | Part 1, Section 3, Rule 3.6 | `Rule 3.6` | +| Does the sentence use a direct action verb? | Part 1, Section 3, Rule 3.7 | `Rule 3.7` | +| Is the sentence complete, clear, and connected? | Part 1, Section 4, Rules 4.1–4.5 | `Rule 4.1` through `Rule 4.5` | +| Does a procedure meet its structural limits? | Part 1, Section 5, Rules 5.1–5.5 | `Rule 5.1` through `Rule 5.5` | +| Does a description present information gradually? | Part 1, Section 6, Rules 6.1–6.6 | `Rule 6.1` through `Rule 6.6` | +| Is the safety signal and consequence correct? | Part 1, Section 7, Rules 7.1–7.3 | `Rule 7.1`, `Rule 7.2`, `Rule 7.3` | +| Is punctuation or word counting uncertain? | Part 1, Section 8, Rules 8.1–8.7 | `Rule 8.1` through `Rule 8.7` | +| Is literal substitution creating unclear text? | Part 1, Section 9, Rule 9.1 | `Rule 9.1` | +| Is wording correct and consistent? | Part 1, Section 9, Rules 9.2–9.4 | `Rule 9.2`, `Rule 9.3`, `Rule 9.4` | +| Is strict vocabulary compliance required? | Part 2, Dictionary | `Part 2`, then the candidate word | + +### When to read there + +| Question | Read when | +|---|---| +| Is a general word approved for this use? | A word's approval, meaning, part of speech, or form is uncertain. | +| Can this domain term be used as a technical noun? | Selecting, shortening, approving, or consistently reusing a technical noun. | +| Can this domain action be used as a technical verb? | A necessary action is absent from the controlled dictionary or its noun/verb role is uncertain. | +| Which spelling convention applies? | Choosing between American English and another officially required spelling. | +| Is a noun stack too long or difficult to parse? | Revising a multi-word noun or introducing a long official term. | +| Are the verb form and tense permitted? | Checking tense, participles, auxiliaries, or an `-ing` form. | +| Is active or passive voice appropriate? | The actor is missing or a description uses passive voice. | +| Does the sentence use a direct action verb? | An abstract noun phrase hides the action. | +| Is the sentence complete, clear, and connected? | Checking omitted words, contractions, lists, connectors, or articles. | +| Does a procedure meet its structural limits? | Checking sentence length, one instruction, imperative form, a preceding condition, or a note. | +| Does a description present information gradually? | Checking sentence length, paragraph topic, paragraph length, keywords, or information order. | +| Is the safety signal and consequence correct? | Selecting a warning or caution and stating the command, hazard, and possible result. | +| Is punctuation or word counting uncertain? | Checking semicolons, hyphens, parentheses, lists, identifiers, units, quoted text, or word counts. | +| Is literal substitution creating unclear text? | A word-for-word replacement is grammatical but difficult to understand. | +| Is wording correct and consistent? | Checking approved-word usage, phrasal verbs, terminology, or style consistency. | +| Is strict vocabulary compliance required? | Every general word must be checked against its entry and approved use. | ## Source-use boundary diff --git a/src/ground.js b/src/ground.js index d301459..f2d0ab9 100644 --- a/src/ground.js +++ b/src/ground.js @@ -3,7 +3,7 @@ import path from 'node:path'; import crypto from 'node:crypto'; import { sections, indentOf, isIndented, columnOf } from './markdown.js'; import { - loadCatalog, isGraded, matrixPathFor, GRADED_DIR, + loadCatalog, isGraded, matrixPathFor, GRADED_DIR, TIERS, } from './catalog.js'; import { walk } from './tree.js'; @@ -1169,6 +1169,10 @@ function withoutFrontMatter(text) { function extract(skillText) { const refusals = []; const { body, offset } = withoutFrontMatter(skillText); + // `offset` is carried out as well as used. It is the count of lines the front + // matter took, and it is zero where there is none, so the caller can ask + // whether this file opens with a block at all. Only `SKILL.md` has a harness + // to read one. const secs = sections(body); const lines = body.split('\n'); // `firstLine` for a setext heading, because `startLine` is the underline and @@ -1193,7 +1197,7 @@ function extract(skillText) { out.push({ text: sec.heading, anchor: sec.heading, block: false }); out.push(...unitsIn(sec.body, sec.heading, at(sec.startLine))); } - return { units: out, refusals }; + return { units: out, refusals, frontMatter: offset }; } export function contentUnits(skillText) { @@ -1264,18 +1268,41 @@ const BROKEN = new Set([ 'row-outside-the-table', ]); +/** + * The one file a harness reads front matter from. + * + * A skill's front matter is metadata: the harness parses the name and the + * description out of it, and never shows it to a writer. That is the whole + * warrant for leaving it out of the units, and it is a fact about `SKILL.md` + * rather than about Markdown. A reference file has no harness, so a closed + * `---` block there is read by nobody: `test/gfm-render.test.js` puts one + * through the parser, and a reader gets a thematic break and a setext HEADING + * carrying every line of the block. The walk removed those lines instead, so a + * directive written there shipped visible to the reader and invisible to the + * check. ADR-0030. + */ +const HARNESS_READS_FRONT_MATTER = 'SKILL.md'; + /** * One graded file against its own matrix. * - * `subject` is the file being graded, and it names the file in the findings - * that speak about it. It defaults to `SKILL.md`, which is what this check read - * until a skill's reference files were graded as well. `matrixPath` is where - * the matrix for that file belongs, and it is a display path rather than - * anything this function opens. + * `subject` is the file being graded. It names the file in the findings that + * speak about it, and it decides whether front matter here is metadata. It has + * no default: the exemption belongs to one file, so a caller that does not say + * which file it is grading may not be handed the exemption. That is the rule + * `now` and `rowDigest`'s pin already obey, and the reason is theirs — a + * default turns a rule off for whoever forgot the argument. + * + * `matrixPath` is where the matrix for that file belongs, and it is a display + * path rather than anything this function opens. */ export function checkSkill({ - skillText, matrixText, now, subject = 'SKILL.md', matrixPath = null, + skillText, matrixText, now, subject, matrixPath = null, }) { + if (typeof subject !== 'string' || !subject) { + throw new TypeError('`subject` must name the file being graded, as a string. ' + + `Got ${JSON.stringify(subject)}.`); + } const today = dayOf(now); if (matrixText === null || matrixText === undefined) { return [{ @@ -1288,7 +1315,7 @@ export function checkSkill({ // The rows that claim a source. The coverage note counts them at the end, and // the source version above the table is required of exactly this set. const sourced = rows.filter((r) => /^G-/i.test(r.id)); - const { units: stmts, refusals } = extract(skillText); + const { units: stmts, refusals, frontMatter } = extract(skillText); const findings = []; // The table itself, before any row in it. A matrix whose header or delimiter @@ -1516,6 +1543,23 @@ export function checkSkill({ } } + // The exemption, checked against the file that has it. The block is still + // removed from the units, because reading it as prose would merge three lines + // a reader sees as a break and a heading into one paragraph nobody wrote. + // Removing it silently was the defect: this refuses instead, so the file + // cannot pass while the block stands. + if (frontMatter && subject !== HARNESS_READS_FRONT_MATTER) { + findings.push({ + level: 'error', + code: 'front-matter-outside-skill-md', + message: `line 1: ${subject} opens with a front matter block, and no harness reads one ` + + 'here. A GFM reader sees a thematic break and a heading carrying every line of it, ' + + 'and this check reads none of them, so a rule written there is disposed of by ' + + 'nothing. Delete the block, or write its contents as ordinary Markdown below the ' + + 'first heading.', + }); + } + // Refusals lead, because every finding under them rests on a reading the // extractor has just said it cannot make. for (const r of refusals) { @@ -1878,62 +1922,99 @@ export function checkShippedFiles({ files, irregular = [], tier, name }) { } /** - * The matrix at a path, or nothing where no matrix stands there. - * - * A directory at that path is nothing too. The finding then names the path and - * says a matrix belongs there, which is what the author has to act on either - * way, and a raw `EISDIR` would stop the whole run over one skill. + * What stands at a matrix path: nothing, something that is not a plain file, or + * the matrix. + * + * The type is asked with `lstat`, and a link is refused rather than read. A + * matrix is identified by its path, so following one lets two graded files + * share a single physical audit record, or lets the check consume a record from + * outside the grounding tree entirely. Neither is visible to the stray scan + * below, because the pathname it walks is exactly the pathname that is held. + * This is the disposition the shipped-file allowlist already gives a link at an + * allowed name, and a study gives a link inside it. + * + * `ENOTDIR` reads as nothing, because a file standing where a directory belongs + * leaves no matrix at the path below it. The stray scan names that file. */ -const matrixAt = async (file) => { +const MATRIX_ABSENT = 'absent'; +const MATRIX_IRREGULAR = 'irregular'; +async function matrixAt(file) { + let stat = null; try { - return await fs.readFile(file, 'utf8'); + stat = await fs.lstat(file); } catch (err) { - if (!['ENOENT', 'EISDIR'].includes(err.code)) throw err; - return null; + if (['ENOENT', 'ENOTDIR'].includes(err.code)) return { state: MATRIX_ABSENT }; + throw err; } -}; + if (!stat.isFile()) return { state: MATRIX_IRREGULAR }; + return { state: 'read', text: await fs.readFile(file, 'utf8') }; +} + +/** Where a stray matrix is reported, when its path names no skill. */ +const NO_SKILL = '(grounding)'; /** - * Every matrix a skill carries that disposes of no file it ships. - * - * The mirror of a graded file with no matrix, and the reason it is an error - * rather than a tidy-up: a matrix nothing reads goes stale unnoticed, and a - * reference file renamed under one leaves the old rows passing every check - * here forever, because no check opens a file nobody names. + * The skill a path under `grounding/` answers to, by its own spelling. + * + * A stray matrix is reported under the name its path implies, so + * `standards/withdrawn/references/guide.md` reaches a reader as `withdrawn` + * even though no such skill exists any more. That is the whole point of the + * scan: the catalogue cannot name it, because the catalogue is what it fell out + * of. A path that implies no skill at all is reported under a name no skill + * directory can hold. */ -async function orphanMatrices(skill, graded) { - const held = new Set(graded.map((rel) => matrixPathFor(skill, rel))); +function skillNamed(rel) { + const parts = rel.split('/'); + if (parts.length < 2 || !TIERS.includes(parts[0])) return NO_SKILL; + return parts.length === 2 ? parts[1].replace(/\.md$/, '') : parts[1]; +} + +/** + * Every file under `grounding/` that disposes of nothing. + * + * This walks the grounding tree and compares it against the matrix paths the + * catalogue answers to. It used to walk from each catalogue skill instead, + * which could not see the case the check exists for: a matrix whose skill was + * deleted or renamed sits under a directory no catalogue entry names, so it was + * never visited and the run stayed green over exactly the stale record this + * refuses. Deriving the skill from the path rather than the path from the skill + * is what closes it, and it catches the same defect one level up — a leftover + * `/.md` for a skill that is gone. + * + * A matrix nothing reads is an error rather than a tidy-up, because its rows go + * stale unread, and no check here opens a file nobody names. + */ +async function strayMatrices(repoRoot, held) { + const root = path.join(repoRoot, 'grounding'); let found = []; try { - found = await walk(skill.groundingDir); + found = await walk(root); } catch (err) { - // Nothing there is the ordinary case. A file there is a shape this walk - // does not model, and it is named rather than thrown, because one odd path - // must not stop the run over every other skill. - if (err.code === 'ENOENT') return []; - if (err.code !== 'ENOTDIR') throw err; - return [{ - level: 'error', - code: 'matrix-grades-nothing', - message: `${path.basename(skill.groundingDir)} is where this skill's reference matrices ` - + 'live, and it is a file. A matrix names the file it grades by its own path, so this ' - + 'one grades nothing. Make it a directory, or delete it.', - }]; + if (['ENOENT', 'ENOTDIR'].includes(err.code)) return []; + throw err; } return found - .map((rel) => path.join(skill.groundingDir, ...rel.split('/'))) - .filter((file) => !held.has(file)) - .map((file) => ({ - level: 'error', - code: 'matrix-grades-nothing', - message: `${path.relative(path.dirname(skill.groundingDir), file)} disposes of no file ` - + 'this skill ships. A matrix names the file it grades by its own path, so either the ' - + 'file moved and this matrix did not, or the matrix is left over. Move it or delete it.', + .filter((rel) => !held.has(path.join(root, ...rel.split('/')))) + .map((rel) => ({ + name: skillNamed(rel), + finding: { + level: 'error', + code: 'matrix-grades-nothing', + message: `grounding/${rel} disposes of no file any skill ships. A matrix names the ` + + 'file it grades by its own path, so either that file moved and the matrix did ' + + 'not, or the skill is gone and the matrix stayed. Move it or delete it.', + }, })); } export async function checkAll(repoRoot, { now } = {}) { const out = {}; + // Every matrix path the catalogue answers to. The stray scan below compares + // the grounding tree against this, so a path is held by the file EXISTING and + // not by the check being able to read it: a graded file refused for not being + // a plain file still has a matrix that grades it, and that matrix is not + // stray. + const held = new Set(); for (const skill of await loadCatalog(repoRoot)) { // `walk` returns names, and a name says nothing about what stands at it. // The type is asked for here, with `lstat`, so the link itself answers @@ -1958,19 +2039,41 @@ export async function checkAll(repoRoot, { now } = {}) { // the link points, and a FIFO at a graded path would hang the run rather // than fail it. That is the reading `src/doctor.js` gives an instruction // file, and the refusal above is the finding either way. + for (const rel of files.filter(isGraded)) held.add(matrixPathFor(skill, rel)); const graded = files.filter((rel) => isGraded(rel) && !irregular.includes(rel)); for (const rel of graded) { const matrixPath = matrixPathFor(skill, rel); + const matrix = await matrixAt(matrixPath); + const shown = path.relative(repoRoot, matrixPath); + if (matrix.state === MATRIX_IRREGULAR) { + // Named and not read, for the reason the graded file above is. The + // path is held either way, so the stray scan says nothing about it and + // this is the only finding that would. + findings.push({ + level: 'error', + code: 'matrix-not-regular', + file: rel, + message: `${shown} is not a plain file. A matrix is identified by its path, so ` + + 'following a link there lets two files share one audit record, or lets this ' + + 'check read a record from outside the grounding tree. Replace it with the ' + + 'matrix itself.', + }); + continue; + } findings.push(...checkSkill({ skillText: await fs.readFile(path.join(skill.dir, ...rel.split('/')), 'utf8'), - matrixText: await matrixAt(matrixPath), + matrixText: matrix.state === MATRIX_ABSENT ? null : matrix.text, now, subject: rel, - matrixPath: path.relative(repoRoot, matrixPath), + matrixPath: shown, }).map((f) => ({ ...f, file: rel }))); } - findings.push(...await orphanMatrices(skill, graded)); out[skill.name] = findings; } + // Last, and over the whole tree rather than per skill. A matrix whose skill + // is gone sits under a directory the catalogue cannot name. + for (const stray of await strayMatrices(repoRoot, held)) { + out[stray.name] = [...(out[stray.name] ?? []), stray.finding]; + } return out; } diff --git a/test/gfm-render.test.js b/test/gfm-render.test.js index caff458..08a1b7a 100644 --- a/test/gfm-render.test.js +++ b/test/gfm-render.test.js @@ -143,7 +143,7 @@ test('a matrix a reader sees damaged is called broken, whatever the damage', () assert.notDeepEqual(asSeen(matrixText), { headings: MATRIX_COLUMNS, ids: wrote }, `${what}: this shape is meant to damage what a reader sees`); - const findings = checkSkill({ skillText: SKILL, matrixText, now: NOW }); + const findings = checkSkill({ subject: 'SKILL.md', skillText: SKILL, matrixText, now: NOW }); const note = (code) => findings.find((f) => f.code === code)?.message; assert.equal(note('audit-coverage'), 'not counted: the matrix table is broken.', `${what}: the audit count`); assert.equal(note('quote-coverage'), 'not counted: the matrix table is broken.', `${what}: the quote count`); @@ -169,7 +169,7 @@ test('where the reader sees a table, the checker reads its rows and no others', const SHAPELESS = new Set(['matrix-no-table', 'matrix-no-header', 'matrix-header-columns', 'matrix-delimiter-columns']); for (const [what, [, lines]] of Object.entries(SHAPES)) { const text = matrix(lines); - const findings = checkSkill({ skillText: SKILL, matrixText: text, now: NOW }); + const findings = checkSkill({ subject: 'SKILL.md', skillText: SKILL, matrixText: text, now: NOW }); if (findings.some((f) => SHAPELESS.has(f.code))) continue; // With multiplicity. A set said `E-01` was seen, over a render that showed // it once and a checker that read it twice, so a dropped row could hide @@ -195,7 +195,7 @@ test('a reader sees no eighth cell', () => { assert.equal(rows[0].length, MATRIX_COLUMNS.length); assert.ok(!rows[0].some((c) => cellText(c) === 'dropped'), 'GFM drops the cell past the last heading'); assert.equal(readMatrix(text).rows[0].cells.length, MATRIX_COLUMNS.length + 1, 'the checker sees it, and reports it'); - const findings = checkSkill({ skillText: SKILL, matrixText: text, now: NOW }); + const findings = checkSkill({ subject: 'SKILL.md', skillText: SKILL, matrixText: text, now: NOW }); assert.ok(findings.some((f) => f.code === 'row-has-extra-cell' && f.level === 'error')); }); @@ -248,7 +248,7 @@ test('a renamed heading renders under its new name, and the record goes with it' const text = matrix([HEADER.replace('Audited', 'Notes'), DELIMITER, row('E-01')]); assert.deepEqual(asSeen(text), asRead(text), 'nothing here divides the reader from the checker'); assert.equal(asSeen(text).headings.at(-1), 'Notes'); - const findings = checkSkill({ skillText: SKILL, matrixText: text, now: NOW }); + const findings = checkSkill({ subject: 'SKILL.md', skillText: SKILL, matrixText: text, now: NOW }); assert.ok(findings.some((f) => f.code === 'matrix-header-column-name' && f.level === 'error')); assert.equal(findings.find((f) => f.code === 'audit-coverage')?.message, 'not counted: the matrix table is broken.'); @@ -449,6 +449,23 @@ test('a table a reader sees without a pipe is refused, and a heading is not', () assert.deepEqual(refusalsFor('Prose here.\n\n:-'), []); }); +test('front matter is invisible to this check and visible to a reader', () => { + // The exemption's whole warrant is that a harness consumes the block as + // metadata, which is true of `SKILL.md` and of no reference file. This is + // what a reader gets for the same bytes where no harness reads them: a + // thematic break, and a setext HEADING carrying every line of the block. The + // walk yields no unit for any of it, so a rule written there was disposed of + // by nothing. `ground --check` refuses the block in a reference file, and + // this render is why the refusal is the honest answer rather than grading + // three lines a reader never sees as prose. + const text = '---\nnote: Always preserve safety.\n---\n\n# Heading\n'; + const html = renderBlocks(text); + assert.match(html, /
/); + assert.match(html, /

note: Always preserve safety\.<\/h2>/); + assert.deepEqual(contentUnits(text).map((u) => u.text), ['Heading']); + assert.deepEqual(unmodelled(text), []); +}); + /** * The blockquote, read as a block, checked against the same parser. * diff --git a/test/ground.test.js b/test/ground.test.js index e638119..ef08dd8 100644 --- a/test/ground.test.js +++ b/test/ground.test.js @@ -60,7 +60,11 @@ const errors = (findings) => findings.filter((f) => f.level !== 'note'); * dated after today is refused against a moment the caller hands in. */ const NOW = '2026-08-06T12:00:00.000Z'; -const check = (args) => checkSkill({ now: NOW, ...args }); +// `subject` names the file being graded, and it has no default: front matter is +// metadata in `SKILL.md` and in nothing else, so a caller that does not say +// which file it grades may not be handed that exemption. Every case below +// grades a skill file unless it says otherwise. +const check = (args) => checkSkill({ now: NOW, subject: 'SKILL.md', ...args }); test('parses rows and skips the separator', () => { const rows = parseMatrix(MATRIX); @@ -161,13 +165,13 @@ test('the check refuses to run without the day, rather than skipping the future // A default would turn the future rule off for whoever forgot the argument, // and `ground --check` already carries the lesson about a gate that fails // open on a missing name. - assert.throws(() => checkSkill({ skillText: SKILL, matrixText: MATRIX }), InvalidMoment); - assert.throws(() => checkSkill({ skillText: SKILL, matrixText: MATRIX, now: 'today' }), InvalidMoment); + assert.throws(() => checkSkill({ subject: 'SKILL.md', skillText: SKILL, matrixText: MATRIX }), InvalidMoment); + assert.throws(() => checkSkill({ subject: 'SKILL.md', skillText: SKILL, matrixText: MATRIX, now: 'today' }), InvalidMoment); // The refusal carries the value and the spellings it accepts. A TypeError // said the type was wrong when the shape is what the check objects to. const thrown = (now) => { - try { checkSkill({ skillText: SKILL, matrixText: MATRIX, now }); return null; } catch (e) { return e; } + try { checkSkill({ subject: 'SKILL.md', skillText: SKILL, matrixText: MATRIX, now }); return null; } catch (e) { return e; } }; const err = thrown('2026-08-06T12:00:00+05:00'); assert.equal(err.name, 'InvalidMoment'); @@ -183,7 +187,7 @@ test('the day the check runs on must itself be a day', () => { // not a day cannot bound anything. const ahead = audited(`9999-12-31 ${CURRENT}`); for (const now of ['9999-99-99', '0000-00-00', '2026-02-31', '2026-08-06extra', '2026-8-6']) { - assert.throws(() => checkSkill({ skillText: SKILL, matrixText: ahead, now }), InvalidMoment, + assert.throws(() => checkSkill({ subject: 'SKILL.md', skillText: SKILL, matrixText: ahead, now }), InvalidMoment, `${now} was accepted as the day the check runs on`); } @@ -191,7 +195,7 @@ test('the day the check runs on must itself be a day', () => { // A bare day and a UTC timestamp are both moments, and both still refuse the // audit dated after them. for (const now of ['2026-08-06', '2026-08-06T12:00:00.000Z', '2026-08-06T12:00Z']) { - assert.ok(checkSkill({ skillText: SKILL, matrixText: ahead, now }) + assert.ok(checkSkill({ subject: 'SKILL.md', skillText: SKILL, matrixText: ahead, now }) .some((f) => f.code === 'audit-ahead-of-the-check'), `${now} was refused`); } }); @@ -203,12 +207,12 @@ test('the day the check runs on is UTC, so an offset cannot move it', () => { // a date, so the grammar refuses the form instead. const seventh = audited(`2026-08-07 ${CURRENT}`); for (const now of ['2026-08-07T00:30:00+05:00', '2026-08-06T19:30:00-05:00', '2026-08-06 12:00:00']) { - assert.throws(() => checkSkill({ skillText: SKILL, matrixText: seventh, now }), InvalidMoment, + assert.throws(() => checkSkill({ subject: 'SKILL.md', skillText: SKILL, matrixText: seventh, now }), InvalidMoment, `${now} was read as a UTC day`); } // The same instant written in UTC is the day the check compares against. - assert.ok(checkSkill({ skillText: SKILL, matrixText: seventh, now: '2026-08-06T19:30:00.000Z' }) + assert.ok(checkSkill({ subject: 'SKILL.md', skillText: SKILL, matrixText: seventh, now: '2026-08-06T19:30:00.000Z' }) .some((f) => f.code === 'audit-ahead-of-the-check')); }); @@ -612,7 +616,7 @@ test('a zero offset is UTC, and a bounded time is required', () => { // warrant that cannot apply to an offset of no hours. for (const now of ['2026-08-06T12:00:00+00:00', '2026-08-06T12:00:00-00:00', '2026-08-06T12:00:00.000+0000', '2026-08-06T12:00:00Z']) { - assert.deepEqual(errors(checkSkill({ skillText: SKILL, matrixText: FULLY, now })), [], + assert.deepEqual(errors(checkSkill({ subject: 'SKILL.md', skillText: SKILL, matrixText: FULLY, now })), [], `${now} is UTC and was refused`); } @@ -620,7 +624,7 @@ test('a zero offset is UTC, and a bounded time is required', () => { // written day put the bound a day early. `99:99:99Z` was simply accepted. for (const now of ['2026-08-06T24:00:00Z', '2026-08-06T99:99:99Z', '2026-08-06T12:60:00Z', '2026-08-06T12:00:00+05:00', '2026-08-06 12:00:00']) { - assert.throws(() => checkSkill({ skillText: SKILL, matrixText: FULLY, now }), InvalidMoment, + assert.throws(() => checkSkill({ subject: 'SKILL.md', skillText: SKILL, matrixText: FULLY, now }), InvalidMoment, `${now} was read as a UTC moment`); } }); @@ -755,11 +759,11 @@ test('a leap second is 23:59:60 and a fraction belongs to the seconds', () => { // `|60` after any minute admitted 1439 times that never existed, and the // fraction sat outside the seconds group so `12:00.500Z` parsed. for (const now of ['2026-08-06T23:59:60Z', '2026-08-06T23:59:60.5Z', '2026-08-06T12:00:00.000Z']) { - assert.deepEqual(errors(checkSkill({ skillText: SKILL, matrixText: MATRIX, now })), [], + assert.deepEqual(errors(checkSkill({ subject: 'SKILL.md', skillText: SKILL, matrixText: MATRIX, now })), [], `${now} is a moment and was refused`); } for (const now of ['2026-08-06T12:00:60Z', '2026-08-06T23:58:60Z', '2026-08-06T12:00.500Z']) { - assert.throws(() => checkSkill({ skillText: SKILL, matrixText: MATRIX, now }), InvalidMoment, + assert.throws(() => checkSkill({ subject: 'SKILL.md', skillText: SKILL, matrixText: MATRIX, now }), InvalidMoment, `${now} is not a moment and was accepted`); } }); @@ -1893,6 +1897,82 @@ test('a reference file no Markdown walk can read is refused by name', async (t) assert.match(refusal.message, /^references\/agents\.yaml/); }); +test('front matter is metadata in SKILL.md and a defect in a reference file', async (t) => { + // The exemption is a fact about the file a harness reads, not about Markdown. + // A reference file has no harness, so a closed `---` block there was removed + // from the units and reported by nothing, while a reader saw every line of it + // inside a heading. `test/gfm-render.test.js` holds that render. + const repo = await withReference(t, `---\nnote: Always preserve safety.\n---\n\n${REFERENCE}`); + const matrix = path.join(repo, 'grounding', 'standards', 'demo-standard', 'references'); + await fsp.mkdir(matrix, { recursive: true }); + await fsp.writeFile(path.join(matrix, 'patterns.md'), REFERENCE_MATRIX); + const found = (await checkAll(repo, { now: NOW }))['demo-standard']; + const refusal = found.find((f) => f.code === 'front-matter-outside-skill-md'); + assert.ok(refusal, `no refusal among: ${JSON.stringify(found)}`); + assert.equal(refusal.level, 'error'); + assert.equal(refusal.file, 'references/patterns.md'); + // The skill's own front matter is untouched, or every skill here would fail. + assert.deepEqual(found.filter((f) => f.code === 'front-matter-outside-skill-md' + && f.file === 'SKILL.md'), []); +}); + +test('the check refuses to run without the file it is grading', () => { + // The same rule the day obeys. Front matter is exempt for one file, so a + // caller that does not name the file may not be handed that exemption. + assert.throws(() => checkSkill({ skillText: SKILL, matrixText: MATRIX, now: NOW }), TypeError); + assert.throws(() => checkSkill({ + skillText: SKILL, matrixText: MATRIX, now: NOW, subject: '', + }), TypeError); +}); + +test('a matrix that is not a plain file is refused and never read', async (t) => { + // A matrix is identified by its path, so following a link there lets two + // graded files share one audit record, or lets the check read a record from + // outside the grounding tree. The stray scan cannot see it either, because + // the pathname it walks is exactly the pathname that is held. + const repo = await withReference(t, REFERENCE); + const matrix = path.join(repo, 'grounding', 'standards', 'demo-standard', 'references'); + await fsp.mkdir(matrix, { recursive: true }); + const outside = path.join(repo, 'outside-matrix.md'); + await fsp.writeFile(outside, REFERENCE_MATRIX); + try { + await fsp.symlink(outside, path.join(matrix, 'patterns.md')); + } catch { + return t.skip('this platform does not let the test create a symbolic link'); + } + const found = (await checkAll(repo, { now: NOW }))['demo-standard']; + const refusal = found.find((f) => f.code === 'matrix-not-regular'); + assert.ok(refusal, `no refusal among: ${JSON.stringify(found)}`); + assert.equal(refusal.level, 'error'); + assert.equal(refusal.file, 'references/patterns.md'); + // Read through the link, the matrix matches and the file passes. It must not. + assert.deepEqual(found.filter((f) => f.file === 'references/patterns.md' + && f.code !== 'matrix-not-regular'), []); + return undefined; +}); + +test('a matrix whose skill is gone is found, under the name its path implies', async (t) => { + // The scan used to start from the catalogue, so a grounding directory for a + // deleted or renamed skill was never visited and the run stayed green over + // exactly the stale record this refuses. Both levels are the same defect: a + // leftover reference matrix, and a leftover matrix for the skill itself. + const repo = await fsp.mkdtemp(path.join(os.tmpdir(), 'sw-stray-')); + t.after(() => fsp.rm(repo, { recursive: true, force: true })); + await fsp.cp(REPO, repo, { recursive: true }); + const gone = path.join(repo, 'grounding', 'standards', 'withdrawn', 'references'); + await fsp.mkdir(gone, { recursive: true }); + await fsp.writeFile(path.join(gone, 'guide.md'), REFERENCE_MATRIX); + await fsp.writeFile(path.join(repo, 'grounding', 'standards', 'retired.md'), REFERENCE_MATRIX); + const all = await checkAll(repo, { now: NOW }); + const strays = Object.entries(all).flatMap(([name, found]) => found + .filter((f) => f.code === 'matrix-grades-nothing').map((f) => [name, f.message])); + assert.equal(strays.length, 2, JSON.stringify(strays)); + assert.ok(strays.some(([name, m]) => name === 'withdrawn' && /withdrawn\/references\/guide\.md/.test(m))); + assert.ok(strays.some(([name, m]) => name === 'retired' && /standards\/retired\.md/.test(m))); + // A skill that is still in the catalogue keeps a clean tree. + assert.deepEqual(all['demo-standard'].filter((f) => f.code === 'matrix-grades-nothing'), []); +}); + test('every file the shipped catalogue grades has a matrix', async () => { // Issue #99 in the shape it was reported: the two STE reference files ship on // every install pathway, and no row disposed of a line in either. From a1768c1bb195bdeafe7af4a8245feeaa84ee42c0 Mon Sep 17 00:00:00 2001 From: logan Date: Fri, 14 Aug 2026 17:52:03 -0400 Subject: [PATCH 3/3] fix: a name JavaScript owns is a skill name, and one render is not five (#99) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of c864215 returned eight findings, two of them blocking, and both blocking ones are in the previous round's own fix batch. `checkAll` keyed its report on a plain object. A stray matrix takes its name from a path, so `grounding/standards/constructor/` read back a FUNCTION rather than nothing, and appending to it threw a TypeError that took the report for every other skill with it. A skill directory called `__proto__` is the same defect pointing the other way: assigning that on an ordinary object invokes the inherited setter, so the skill would have left the report in silence. The report is a `Map` now, converted through `Object.fromEntries` onto a null prototype, which is how the install statement's `keep` has been built since ADR-0019. The command line asks `Object.hasOwn` rather than `in`, so a name the object merely inherits is unknown there whatever the callee returns. Four documents stated as parser-backed fact that a reference file's front matter renders as a thematic break and a setext heading. That is one shape's render. A list inside the block renders as a list, a fenced block as code, and a table as a table, so the sentence was false in three of the four shapes nobody had asked the parser about. This is the comment that explains away what the oracle was never shown, which this repository forbids by name. The oracle carries five shapes now, and all four places state the property they share: a reader sees the block's contents, and the walk reads no unit from any line of it. The message an author reads leads with what the check cannot read, because that clause is true of THEIR file whatever shape their block takes. The refusal itself was sound in every shape and did not move. Three smaller readings were wrong for the same reason the majors were: a path was trusted to answer a question the filesystem answers. A directory at a matrix path got a message about symbolic links, so the type found is named. A case-folding filesystem resolves two spellings to one file, so a miscased matrix was read as the matrix AND reported as a stray whose remedy said delete it, and the scan now asks for identity where the spelling misses. The `lstat` answers for the last path component alone, and a linked intermediate directory still resolves out of the tree, which the stray scan catches as a red run rather than a wrong reading — that limit is stated in ADR-0030 rather than closed, because closing it needs `realpath` on both sides of a containment test and would refuse a checkout reached through a linked path. Two documents kept the pre-delta scope of the stray refusal, and the installed navigation file carried a sentence of audit-record rationale that belongs in the matrix. The `G` row over the location table claims the location and not the label, and both the ADR and the matrix now say so, because the question column is our own paraphrase and no paraphrase becomes the standard's by sitting beside a rule number. Each code fix was flipped off and watched to fail before it was restored. The counts did not move: 127 rows across the two matrices, twelve of them `G` rows, every one still `unaudited` and `unquoted`. --- AGENTS.md | 20 ++- CHANGELOG.md | 10 +- CONTRIBUTING.md | 3 +- README.md | 3 +- .../adr/0030-a-matrix-disposes-of-one-file.md | 53 ++++++-- .../references/rule-navigation.md | 9 +- .../references/rule-navigation.md | 3 +- src/cli.js | 5 +- src/ground.js | 114 +++++++++++++++--- test/gfm-render.test.js | 37 ++++-- test/ground.test.js | 78 ++++++++++++ 11 files changed, 279 insertions(+), 56 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a603baa..a79c475 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,8 @@ skill can carry the same heading, and a shared space let a row claim an occurrence in the file nobody wrote it for while every cell still matched. The file identity sits in the matrix's own path, where a filesystem holds it rather than a cell, so no column moved and no digest changed. A graded file with no -matrix is refused, and so is a matrix that grades no file. `references/` holds +matrix is refused, and so is a file under `grounding/` that grades no file any +skill ships. `references/` holds Markdown, because the walk reads Markdown alone and nothing can grade a file it cannot read. ADR-0030 records the decision. @@ -90,14 +91,21 @@ thing outside the check, because it is metadata for the harness. That exemption belongs to `SKILL.md` and to no other file. The harness parses a skill's front matter and never shows it to a writer, which is the whole warrant, and no harness reads a reference file's prefix. A closed `---` block there was -removed from the units and reported by nothing, while a reader saw a thematic -break and a heading carrying every line of it, so a rule written there shipped +removed from the units and reported by nothing, so a rule written there shipped visible to the reader and invisible to the check. `checkSkill` takes the file it is grading as `subject`, with no default, because a caller that does not say which file it grades may not be handed the exemption. A block in any other file -is refused by name. The render is in `test/gfm-render.test.js`, by ADR-0028's -rule, and it decided the disposition: three lines a reader sees as a break and a -heading are not graded as prose nobody wrote. +is refused by name. + +State what that block renders as carefully, because it depends on the lines. +`test/gfm-render.test.js` puts five shapes through the parser, by ADR-0028's +rule: a mapping gives a thematic break and a setext heading, a list gives a +list, a fenced block gives code, and a table gives a table. An earlier draft of +this paragraph named the first render as the reason, in four documents at once, +which the parser refutes for the other four shapes. The property that holds +across all of them is the one the refusal rests on, and it is the one to state: +a reader sees the block's contents, and the walk reads no unit from any line of +it. Each row claims one occurrence. A skill that repeats a sentence needs a row for each time it says it. diff --git a/CHANGELOG.md b/CHANGELOG.md index a10233a..c6995b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,9 +27,13 @@ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). because the walk reads Markdown alone. A front matter block outside `SKILL.md` is refused, because the harness that reads one is the whole warrant for the exemption, and `checkSkill` takes the file it grades as `subject` with no - default. Every finding names the file it came from, beside the skill. - ADR-0030 records the decision, and it amends ADR-0025's count from a note to - an error. Issue #99. + default. Every finding names the file it came from, beside the skill. The + report is keyed prototype-safely, so a skill directory or a stray matrix at a + name JavaScript owns reaches a reader rather than emptying the report or + ending the run. A matrix and a stray are compared by the filesystem's own + identity where two spellings resolve to one file, so a miscased matrix is the + matrix rather than a stray to delete. ADR-0030 records the decision, and it + amends ADR-0025's count from a note to an error. Issue #99. - `skills/standards/simplified-technical-english/references/rule-navigation.md` carries two tables where it carried one. A table is one unit, so a table is one authority class, and the single table mixed source locations with our own diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 021f8e8..8ae237e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -313,7 +313,8 @@ grounding/standards/demo/references/examples.md Write the reference matrix the way you write the skill's own. It carries the same seven columns, its own quotation declaration, and its own source version when it holds a `G` row. A reference file with no matrix fails the check, and -so does a matrix that grades no file. ADR-0030 gives the reason. +so does a file under `grounding/` that grades no file any skill ships. ADR-0030 +gives the reason. A file under `references/` is Markdown. The walk reads Markdown alone, so nothing can grade a file of another kind, and the check refuses one by name. diff --git a/README.md b/README.md index af3f5f4..dfc6a4e 100644 --- a/README.md +++ b/README.md @@ -319,7 +319,8 @@ matrix that mirrors its path, such as `grounding/standards/simplified-technical-english/references/examples.md`. A row names a heading, and two files in one skill can carry the same heading, so a shared row space would let a row claim the wrong occurrence. A file with no -matrix fails the check, and so does a matrix that grades no file. ADR-0030 +matrix fails the check, and so does a file under `grounding/` that grades no +file any skill ships. ADR-0030 records the decision. Rows come in three kinds: diff --git a/docs/adr/0030-a-matrix-disposes-of-one-file.md b/docs/adr/0030-a-matrix-disposes-of-one-file.md index 614a3ff..69bbf91 100644 --- a/docs/adr/0030-a-matrix-disposes-of-one-file.md +++ b/docs/adr/0030-a-matrix-disposes-of-one-file.md @@ -90,15 +90,25 @@ review found all three at once. *Front matter is metadata.* True of `SKILL.md`, whose block the harness parses and never shows a writer. No harness reads a reference file's prefix, so a -closed `---` block there was removed from the units and reported by nothing, -while `micromark` renders a thematic break and a setext heading carrying every -line of it. A rule written there shipped visible to the reader and invisible to -the check. The block is refused in any subject but `SKILL.md`, and it is still -removed from the units rather than graded, because reading three lines as prose -would ground a paragraph no reader sees. `checkSkill` takes `subject` with no +closed `---` block there was removed from the units and reported by nothing. A +rule written there shipped visible to the reader and invisible to the check. The +block is refused in any subject but `SKILL.md`, and it is still removed from the +units rather than graded, because a reader does not see those lines as the +paragraph the walk would make of them. `checkSkill` takes `subject` with no default, so a caller that does not name the file it grades cannot be handed the exemption. That is the rule `now` obeys, for its reason. +What the block renders as depends on the lines, and the reason stated here has +to survive every shape. `micromark` gives a thematic break and a setext heading +for a mapping, a list for a list, code for a fenced block, and a table for a +table. This ADR first named the mapping's render as the reason, and AGENTS.md, +the code and the author-facing message repeated it, so one shape's render stood +in four places as a fact about all of them. That is the comment explaining away +what the parser was never asked, which this repository forbids by name. The +oracle carries all five shapes now, and every one of those places states the +property they share: a reader sees the block's contents, and the walk reads no +unit from any line of it. + *The grounding tree is reachable from the catalogue.* It is not. A matrix whose skill was deleted or renamed sits under a directory no catalogue entry names, so walking out from each skill never visited it and the run stayed green over @@ -112,7 +122,27 @@ path, so following a link there lets two graded files share one physical audit record, or lets the check read a record from outside the grounding tree. The stray scan cannot see either, because the link stands at exactly the pathname the scan holds. This is the disposition the shipped-file allowlist already gives -a link at an allowed name. +a link at an allowed name. The type found is named in the finding, because a +directory at a matrix path and a link at one need different remedies. + +That `lstat` answers for the LAST component and no other. A link standing as an +intermediate directory still lets the read resolve out of the tree, so the +findings printed would come from a foreign record. It is not a green run: `walk` +reports a linked directory as a file entry, so the component is a stray and the +gate fails. The limit is stated rather than closed, because closing it needs +`realpath` on both sides of a containment test, and a checkout reached through a +linked path — `/tmp` on macOS is one — would then be refused for its own layout. +The reading is wrong there and the verdict is not, and this is where a reader +finds that out. + +*A spelling names a file.* Only where the filesystem agrees. A case-folding +filesystem resolves two spellings to one file, so a miscased matrix was read at +the held spelling and reported as a stray at the walked one, with a remedy +telling the author to delete the file the check had just used. The scan compares +the spelling first and the filesystem's own identity where the spelling misses, +which is the question the install engine already asks of a destination. The +identity comes from `lstat`, so a link never answers for its target, and it is +withheld where an inode reads zero. ## The count was a note, and it is an error now @@ -144,7 +174,14 @@ carried one table whose `Read when` column is our own advice, and a `G` row over that designator attributed every recommendation in it to Rules 1.1 through 9.4. The designator cannot be split, so the FILE was: it carries a table of source locations graded `G`, and a table of our advice graded `E`. The questions repeat -across both, which is the cost, and no row mixes the two. +across both, which is the cost. + +The `G` row claims the location and not the label. Its `Question` column stays +our own paraphrase of a topic, which the file says of itself, and a paraphrase +does not become the standard's by sitting beside a rule number. That is true of +every `G` row here: the guidance cell is always our words, and what the row +traces is the claim, not the wording. Splitting further would grade our own +question text against a rule, which is the defect pointing the other way. ## What this does not claim diff --git a/grounding/standards/simplified-technical-english/references/rule-navigation.md b/grounding/standards/simplified-technical-english/references/rule-navigation.md index 4799716..f019791 100644 --- a/grounding/standards/simplified-technical-english/references/rule-navigation.md +++ b/grounding/standards/simplified-technical-english/references/rule-navigation.md @@ -22,7 +22,12 @@ designator attributed every recommendation in it to Rules 1.1 through 9.4. The file now carries two tables. The first states where the standard answers each question, and it is the `G` row. The second is our advice about when to read there, and it is an `E` row. The questions repeat across both, which is the -cost of splitting them, and no row mixes the two. +cost of splitting them. + +The `G` row claims the LOCATION and not the label. Its `Question` column is our +own paraphrase of a topic, as row `E-01` says of every topic label in this +file, and no paraphrase of ours becomes the standard's by sitting beside a rule +number. What the row traces to the source is where each question is answered. The `G` row's rule cell names the whole range its table covers, because the table covers a range. The row cites the reading recorded in @@ -51,7 +56,7 @@ the skill moves to a new issue, and every audit here goes stale at once. | N-01 | ASD-STE100 Issue 9 Rule Navigation | ASD-STE100 Issue 9 Rule Navigation | | | Section title, asserts no rule | | | E-01 | Use this map to find relevant material in the official standard. The topic labels below are paraphrases, not rule text. Search the [official Issue 9 PDF](https://www.asd-ste100.org/assets/files/ASD-STE100_ISSUE9.pdf) by rule identifier and confirm the complete rule, explanation, and applicable dictionary entries there. | ASD-STE100 Issue 9 Rule Navigation | | | Our own routing instruction, not a rule of the standard | | | N-02 | Writing-rule map | Writing-rule map | | | Section title, asserts no rule | | -| N-03 | The first table states where the standard answers each question. The second table is our own advice about when to read there. The questions repeat across both, so each table carries one kind of claim and no row mixes the two. | Writing-rule map | | | Describes how this file is laid out, asserts no rule | | +| N-03 | The first table states where the standard answers each question. The second table is our own advice about when to read there. | Writing-rule map | | | Describes how this file is laid out, asserts no rule | | | G-01 | [table 42e8c66c] | Writing-rule map | Rules 1.1 through 9.4, and Part 2 | unquoted | Part 1, Sections 1 to 9, and Part 2 | unaudited | | N-04 | When to read there | When to read there | | | Section title, asserts no rule | | | E-02 | [table 11979737] | When to read there | | | Our own advice about when to open the standard | | diff --git a/skills/standards/simplified-technical-english/references/rule-navigation.md b/skills/standards/simplified-technical-english/references/rule-navigation.md index 4e46ea3..006b847 100644 --- a/skills/standards/simplified-technical-english/references/rule-navigation.md +++ b/skills/standards/simplified-technical-english/references/rule-navigation.md @@ -5,8 +5,7 @@ Use this map to find relevant material in the official standard. The topic label ## Writing-rule map The first table states where the standard answers each question. The second -table is our own advice about when to read there. The questions repeat across -both, so each table carries one kind of claim and no row mixes the two. +table is our own advice about when to read there. | Question | Official location | Search in the PDF | |---|---|---| diff --git a/src/cli.js b/src/cli.js index e19d86e..5824e00 100644 --- a/src/cli.js +++ b/src/cli.js @@ -256,7 +256,10 @@ export async function run(argv, ctx) { // that matters most: `ground --check` is a CI gate, and a name it does not // know contributed no findings and reported "Grounding clean." A gate that // fails open on a typo or a renamed skill is worse than no gate. - const unknown = names.filter((n) => !(n in all)); + // `Object.hasOwn`, so a name the object inherits is unknown here whatever + // prototype the result carries. `constructor in all` answered true on a + // plain object, and the loop below then spread a function. + const unknown = names.filter((n) => !Object.hasOwn(all, n)); if (unknown.length) { say(`Unknown skill: ${unknown.join(', ')}.`); say(`Available: ${Object.keys(all).sort().join(', ')}.`); diff --git a/src/ground.js b/src/ground.js index f2d0ab9..5da9e71 100644 --- a/src/ground.js +++ b/src/ground.js @@ -1275,11 +1275,16 @@ const BROKEN = new Set([ * description out of it, and never shows it to a writer. That is the whole * warrant for leaving it out of the units, and it is a fact about `SKILL.md` * rather than about Markdown. A reference file has no harness, so a closed - * `---` block there is read by nobody: `test/gfm-render.test.js` puts one - * through the parser, and a reader gets a thematic break and a setext HEADING - * carrying every line of the block. The walk removed those lines instead, so a - * directive written there shipped visible to the reader and invisible to the - * check. ADR-0030. + * `---` block there is metadata to nobody. + * + * What a reader sees instead depends on the lines. `test/gfm-render.test.js` + * puts five shapes through the parser: a mapping renders as a thematic break + * and a setext heading, a list renders as a list, a fenced block as code, and a + * table as a table. Naming one of those as the reason would be a claim the + * parser refutes for the other four. What holds across all of them is what this + * refusal rests on: a reader sees the block's contents, and the walk reads no + * unit from any line of it. So a directive written there shipped visible to the + * reader and invisible to the check. ADR-0030. */ const HARNESS_READS_FRONT_MATTER = 'SKILL.md'; @@ -1553,10 +1558,10 @@ export function checkSkill({ level: 'error', code: 'front-matter-outside-skill-md', message: `line 1: ${subject} opens with a front matter block, and no harness reads one ` - + 'here. A GFM reader sees a thematic break and a heading carrying every line of it, ' - + 'and this check reads none of them, so a rule written there is disposed of by ' - + 'nothing. Delete the block, or write its contents as ordinary Markdown below the ' - + 'first heading.', + + 'here. This check reads no unit from those lines, and a reader sees their contents ' + + 'as whatever the lines make. So a rule written there is disposed of by nothing. ' + + 'Delete the block, or write its contents as ordinary Markdown below the first ' + + 'heading.', }); } @@ -1935,6 +1940,15 @@ export function checkShippedFiles({ files, irregular = [], tier, name }) { * * `ENOTDIR` reads as nothing, because a file standing where a directory belongs * leaves no matrix at the path below it. The stray scan names that file. + * + * This answers for the LAST component of the path and no other. A symbolic link + * standing as an intermediate directory still lets `readFile` resolve out of + * the tree, and the findings printed would come from a record that is not + * ours. What stops that being a green run is the scan below: `walk` reports a + * linked directory as a file entry, because `isDirectory` is false for a link, + * so the component is a stray and the run is red. The gate holds and the + * reading is wrong, which is why this is written down rather than left to be + * rediscovered. ADR-0030 records the limit. */ const MATRIX_ABSENT = 'absent'; const MATRIX_IRREGULAR = 'irregular'; @@ -1946,10 +1960,42 @@ async function matrixAt(file) { if (['ENOENT', 'ENOTDIR'].includes(err.code)) return { state: MATRIX_ABSENT }; throw err; } - if (!stat.isFile()) return { state: MATRIX_IRREGULAR }; + // What stands there is named, because the remedy differs. A link is followed + // and a directory cannot be written into, and one message about links told + // the author of a directory the wrong thing. + if (stat.isDirectory()) return { state: MATRIX_IRREGULAR, kind: 'a directory' }; + if (stat.isSymbolicLink()) return { state: MATRIX_IRREGULAR, kind: 'a symbolic link' }; + if (!stat.isFile()) return { state: MATRIX_IRREGULAR, kind: 'not a plain file' }; return { state: 'read', text: await fs.readFile(file, 'utf8') }; } +/** + * What the filesystem calls this file, rather than what the path spells. + * + * Two spellings can be one file. A case-folding filesystem resolves + * `references/Patterns.md` and `references/patterns.md` to the same bytes, so + * the matrix was read at the held spelling AND reported as a stray at the + * spelling the walk returned, with a remedy telling the author to delete the + * file the check had just used. The install engine already answers this by + * asking the filesystem whether a destination is still the file the statement + * named, and this is that question one directory over. + * + * `lstat`, so a link never answers for its target: a link beside the matrix it + * points at is two files, and the stray scan is what names it. + * + * An inode of zero identifies nothing, and some filesystems report one, so the + * identity is withheld there and the comparison falls back to the spelling. + */ +async function identityOf(file) { + try { + const stat = await fs.lstat(file); + return stat.ino ? `${stat.dev}:${stat.ino}` : null; + } catch (err) { + if (['ENOENT', 'ENOTDIR'].includes(err.code)) return null; + throw err; + } +} + /** Where a stray matrix is reported, when its path names no skill. */ const NO_SKILL = '(grounding)'; @@ -1993,8 +2039,23 @@ async function strayMatrices(repoRoot, held) { if (['ENOENT', 'ENOTDIR'].includes(err.code)) return []; throw err; } - return found - .filter((rel) => !held.has(path.join(root, ...rel.split('/')))) + // The spelling first, and the filesystem's own answer where the spelling + // misses. A matrix the check just read must never be reported as a stray for + // being spelled with another case. + const ids = new Set(); + for (const file of held) { + const id = await identityOf(file); + if (id) ids.add(id); + } + const stray = []; + for (const rel of found) { + const file = path.join(root, ...rel.split('/')); + if (held.has(file)) continue; + const id = await identityOf(file); + if (id && ids.has(id)) continue; + stray.push(rel); + } + return stray .map((rel) => ({ name: skillNamed(rel), finding: { @@ -2008,7 +2069,15 @@ async function strayMatrices(repoRoot, held) { } export async function checkAll(repoRoot, { now } = {}) { - const out = {}; + // Keyed by skill name, in a `Map`, and prototype-safely for the reason the + // install statement's `keep` is built that way. A skill directory may be + // called `__proto__`, and assigning that on an ordinary object invokes the + // inherited setter rather than creating a property, so the skill would leave + // the report without a word. A stray matrix supplies the other half: its name + // comes from a path, so `grounding/standards/constructor/` reads back a + // FUNCTION rather than `undefined`, and appending to it threw a `TypeError` + // that took the report for every other skill with it. + const out = new Map(); // Every matrix path the catalogue answers to. The stray scan below compares // the grounding tree against this, so a path is held by the file EXISTING and // not by the check being able to read it: a graded file refused for not being @@ -2053,10 +2122,10 @@ export async function checkAll(repoRoot, { now } = {}) { level: 'error', code: 'matrix-not-regular', file: rel, - message: `${shown} is not a plain file. A matrix is identified by its path, so ` - + 'following a link there lets two files share one audit record, or lets this ' - + 'check read a record from outside the grounding tree. Replace it with the ' - + 'matrix itself.', + message: `${shown} is ${matrix.kind}, and a matrix is a plain file. Identity here ` + + 'is the path, so following a link lets two files share one audit record, or ' + + 'lets this check read a record from outside the grounding tree. Put the matrix ' + + 'for this file at that path.', }); continue; } @@ -2068,12 +2137,17 @@ export async function checkAll(repoRoot, { now } = {}) { matrixPath: shown, }).map((f) => ({ ...f, file: rel }))); } - out[skill.name] = findings; + out.set(skill.name, findings); } // Last, and over the whole tree rather than per skill. A matrix whose skill // is gone sits under a directory the catalogue cannot name. for (const stray of await strayMatrices(repoRoot, held)) { - out[stray.name] = [...(out[stray.name] ?? []), stray.finding]; + out.set(stray.name, [...(out.get(stray.name) ?? []), stray.finding]); } - return out; + // The object the callers read, with no prototype. `Object.fromEntries` gives + // `__proto__` an own property rather than invoking a setter, and the null + // prototype is what stops a caller's `name in all` answering for + // `constructor` or `toString`. Both halves are needed: one fixes the write, + // and the other fixes every read. + return Object.assign(Object.create(null), Object.fromEntries(out)); } diff --git a/test/gfm-render.test.js b/test/gfm-render.test.js index 08a1b7a..36e75b9 100644 --- a/test/gfm-render.test.js +++ b/test/gfm-render.test.js @@ -452,18 +452,31 @@ test('a table a reader sees without a pipe is refused, and a heading is not', () test('front matter is invisible to this check and visible to a reader', () => { // The exemption's whole warrant is that a harness consumes the block as // metadata, which is true of `SKILL.md` and of no reference file. This is - // what a reader gets for the same bytes where no harness reads them: a - // thematic break, and a setext HEADING carrying every line of the block. The - // walk yields no unit for any of it, so a rule written there was disposed of - // by nothing. `ground --check` refuses the block in a reference file, and - // this render is why the refusal is the honest answer rather than grading - // three lines a reader never sees as prose. - const text = '---\nnote: Always preserve safety.\n---\n\n# Heading\n'; - const html = renderBlocks(text); - assert.match(html, /
/); - assert.match(html, /

note: Always preserve safety\.<\/h2>/); - assert.deepEqual(contentUnits(text).map((u) => u.text), ['Heading']); - assert.deepEqual(unmodelled(text), []); + // what a reader gets for the same bytes where no harness reads them. + // + // The property is stated as one rule over many shapes, and not as the render + // of any one of them. A first draft asserted a thematic break and a setext + // heading, which is what the first shape below produces and what three of the + // others do not: a list inside the block renders as a list, a fenced block as + // code, and a table as a table. Writing that one render into four documents + // as the reason for the refusal was the comment explaining away what the + // parser had not been asked. What holds across every shape is the thing the + // refusal actually rests on: a reader sees the block's contents, and this + // check reads no unit from any line of it. + const shapes = { + 'a mapping': '---\nnote: Always preserve safety.\n---\n\n# Heading', + 'a list': '---\n- Always preserve safety.\n---\n\n# Heading', + 'a fenced block': '---\n```\nAlways preserve safety.\n```\n---\n\n# Heading', + 'a blank line inside': '---\na: b\n\nc: Always preserve safety.\n---\n\n# Heading', + 'a table': '---\n| Always preserve safety. | b |\n|---|---|\n---\n\n# Heading', + }; + for (const [name, text] of Object.entries(shapes)) { + assert.match(renderBlocks(text), /Always preserve safety\./, + `a reader sees the block's contents in ${name}`); + assert.deepEqual(contentUnits(text).map((u) => u.text), ['Heading'], + `the walk reads no unit from the block in ${name}`); + assert.deepEqual(unmodelled(text), [], `and refuses no line of it in ${name}`); + } }); /** diff --git a/test/ground.test.js b/test/ground.test.js index ef08dd8..09e5ccf 100644 --- a/test/ground.test.js +++ b/test/ground.test.js @@ -1973,6 +1973,84 @@ test('a matrix whose skill is gone is found, under the name its path implies', a assert.deepEqual(all['demo-standard'].filter((f) => f.code === 'matrix-grades-nothing'), []); }); +test('a directory at a matrix path is named for what it is', async (t) => { + // One message about links told the author of a directory the wrong thing, + // and the "write one at" remedy went with it. The type found is named now. + const repo = await withReference(t, REFERENCE); + const matrix = path.join(repo, 'grounding', 'standards', 'demo-standard', 'references'); + await fsp.mkdir(path.join(matrix, 'patterns.md'), { recursive: true }); + const found = (await checkAll(repo, { now: NOW }))['demo-standard']; + const refusal = found.find((f) => f.code === 'matrix-not-regular'); + assert.ok(refusal, `no refusal among: ${JSON.stringify(found)}`); + assert.match(refusal.message, /is a directory/); + assert.doesNotMatch(refusal.message, /Write one at/); +}); + +/** Whether this filesystem resolves two spellings of one name to one file. */ +async function foldsCase(dir) { + await fsp.writeFile(path.join(dir, 'case-probe.tmp'), 'x'); + try { + await fsp.stat(path.join(dir, 'CASE-PROBE.tmp')); + return true; + } catch { + return false; + } finally { + await fsp.rm(path.join(dir, 'case-probe.tmp'), { force: true }); + } +} + +test('a miscased matrix is the matrix, not a stray to delete', async (t) => { + // Two spellings can be one file. The check read the matrix at the held + // spelling and then reported it as a stray at the walked one, telling the + // author to delete the file it had just used. The install engine asks the + // filesystem this question already. + const repo = await withReference(t, REFERENCE); + if (!await foldsCase(repo)) return t.skip('this filesystem does not fold case'); + const matrix = path.join(repo, 'grounding', 'standards', 'demo-standard', 'references'); + await fsp.mkdir(matrix, { recursive: true }); + await fsp.writeFile(path.join(matrix, 'Patterns.md'), REFERENCE_MATRIX); + const found = (await checkAll(repo, { now: NOW }))['demo-standard']; + assert.deepEqual(found.filter((f) => f.code === 'matrix-grades-nothing'), [], + 'the matrix the check read is not a stray'); + assert.deepEqual(errors(found), [], `the file is graded: ${JSON.stringify(errors(found))}`); + return undefined; +}); + +test('a name JavaScript owns is a skill name like any other', async (t) => { + // A stray's name comes from a path, so `grounding/standards/constructor/` + // read back a FUNCTION rather than nothing, and appending to it threw and + // took the report for every other skill with it. A skill directory called + // `__proto__` is the other half: assigning that on an ordinary object + // invokes the inherited setter, so the skill left the report in silence. + // `keep` in the install statement is built prototype-safely for that reason. + const repo = await fsp.mkdtemp(path.join(os.tmpdir(), 'sw-proto-')); + t.after(() => fsp.rm(repo, { recursive: true, force: true })); + await fsp.cp(REPO, repo, { recursive: true }); + for (const name of ['constructor', 'toString', '__proto__']) { + const gone = path.join(repo, 'grounding', 'standards', name, 'references'); + await fsp.mkdir(gone, { recursive: true }); + await fsp.writeFile(path.join(gone, 'guide.md'), REFERENCE_MATRIX); + } + const owned = path.join(repo, 'skills', 'craft', '__proto__'); + await fsp.mkdir(owned, { recursive: true }); + await fsp.writeFile(path.join(owned, 'SKILL.md'), + '---\nname: __proto__\ndescription: A skill at a name JavaScript owns.\n---\n\n# Owned\n'); + + const all = await checkAll(repo, { now: NOW }); + for (const name of ['constructor', 'toString']) { + assert.ok(Object.hasOwn(all, name), `${name} reached the report`); + assert.ok(all[name].some((f) => f.code === 'matrix-grades-nothing')); + } + // `__proto__` is both a stray matrix and a real skill here, so its entry + // carries the skill's own findings and the stray beside them. + assert.ok(Object.hasOwn(all, '__proto__'), 'the skill reached the report'); + assert.ok(all['__proto__'].some((f) => f.code === 'no-matrix')); + assert.ok(all['__proto__'].some((f) => f.code === 'matrix-grades-nothing')); + // Nothing the object merely inherits reads as a skill. + assert.ok(!Object.hasOwn(all, 'hasOwnProperty')); + assert.equal(all.valueOf, undefined, 'the result carries no prototype'); +}); + test('every file the shipped catalogue grades has a matrix', async () => { // Issue #99 in the shape it was reported: the two STE reference files ship on // every install pathway, and no row disposed of a line in either.