@@ -49,7 +49,9 @@ public static string ToMarkdown(AnalysisResult result, ReportOptions? options =
4949 ? text . Get ( ReportMessages . DefaultTitle ) . Text
5050 : o . Title ;
5151
52- sb . Append ( "# " ) . Append ( title ) . AppendLine ( ) ;
52+ // Through Cell like everything else this report did not write: the title is a caller's string,
53+ // and every host that has one builds it from a filename.
54+ sb . Append ( "# " ) . Append ( Cell ( title ) ) . AppendLine ( ) ;
5355 sb . AppendLine ( ) ;
5456 var fallbackNoticeAt = sb . Length ;
5557 if ( ! string . IsNullOrWhiteSpace ( o . DocumentName ) )
@@ -144,7 +146,7 @@ public static string ToMarkdown(AnalysisResult result, ReportOptions? options =
144146 {
145147 AppendHeading ( sb , text , 3 , ReportMessages . SectionCharacters ) ;
146148 sb . AppendLine ( ) ;
147- sb . AppendLine ( result . Artifacts . Summary ) ;
149+ sb . AppendLine ( Cell ( result . Artifacts . Summary ) ) ;
148150 sb . AppendLine ( ) ;
149151 // The heading used to say "characters writing does not produce", which is false for
150152 // half of what this table lists: Word makes soft hyphens on its own and a stray
@@ -155,7 +157,15 @@ public static string ToMarkdown(AnalysisResult result, ReportOptions? options =
155157 sb . AppendLine ( ) ;
156158 AppendBlock ( sb , text , ReportMessages . CharactersTableHeader ) ;
157159 sb . AppendLine ( "|---|---|---:|---:|" ) ;
158- foreach ( var occurrence in result . Artifacts . Occurrences . Take ( o . MaxRows ) )
160+ // Strong kinds first, then by position. A file can hold two hundred soft hyphens —
161+ // Word inserts them unprompted — and one letter borrowed from another alphabet. In
162+ // document order the innocent two hundred fill the table and the one occurrence that
163+ // is hard to arrive at by accident falls off the end. IsStrong is the scanner's own
164+ // published distinction, not a new judgement invented for the page.
165+ foreach ( var occurrence in result . Artifacts . Occurrences
166+ . OrderByDescending ( a => a . IsStrong )
167+ . ThenBy ( a => a . Line ) . ThenBy ( a => a . Column )
168+ . Take ( o . MaxRows ) )
159169 sb . Append ( "| " ) . Append ( Cell ( Describe ( occurrence . Kind ) ) ) . Append ( " | `" )
160170 . Append ( occurrence . CodePoint ) . Append ( "` | " ) . Append ( occurrence . Line )
161171 . Append ( " | " ) . Append ( occurrence . Column ) . AppendLine ( " |" ) ;
@@ -172,10 +182,19 @@ public static string ToMarkdown(AnalysisResult result, ReportOptions? options =
172182 {
173183 AppendHeading ( sb , text , 3 , ReportMessages . SectionCitations ) ;
174184 sb . AppendLine ( ) ;
175- sb . AppendLine ( result . Citations . Summary ) ;
185+ sb . AppendLine ( Cell ( result . Citations . Summary ) ) ;
176186 sb . AppendLine ( ) ;
177187 foreach ( var issue in result . Citations . Issues . Take ( o . MaxRows ) )
178188 sb . Append ( "- " ) . AppendLine ( Cell ( issue . Message ) ) ;
189+ // This list used to stop at forty in silence, alone among the four. A reader counting
190+ // the contradictions on the page against the number the summary above states would
191+ // find the page contradicting itself about a document accused of contradicting itself.
192+ if ( result . Citations . Issues . Count > o . MaxRows )
193+ {
194+ sb . AppendLine ( ) ;
195+ AppendBlock ( sb , text , ReportMessages . MoreRows ,
196+ result . Citations . Issues . Count - o . MaxRows ) ;
197+ }
179198 sb . AppendLine ( ) ;
180199 // Only claimed when something actually contradicts. The first version printed it
181200 // whenever there was anything to say about sources at all — including "no reference
@@ -199,7 +218,37 @@ public static string ToMarkdown(AnalysisResult result, ReportOptions? options =
199218 }
200219 else
201220 {
202- foreach ( var f in result . Signals . Take ( o . MaxRows ) )
221+ // Strongest first, and the page says so. The analyser returns findings in the order they
222+ // occur in the text, because that is what highlighting and the rewriter need; printing
223+ // them that way and then cutting at forty meant a long document could spend the whole
224+ // list on weak hits in its opening pages while the finding that did most to produce the
225+ // headline number sat in the last paragraph, omitted. The reader was then given a score
226+ // the visible evidence could not account for — in the one document this project builds
227+ // for somebody to take into a room where a decision is made about a person.
228+ //
229+ // Weight is the finding's own contribution to the score. Ties are broken by how many
230+ // times that rule has already appeared, and only then by position: sixteen English rules
231+ // share the weight 3.5, and in a tie the reader is better served by one occurrence of
232+ // each before any second occurrence than by one rule's run. It reorders strictly within
233+ // equal weight, so it costs the guarantee nothing — everything omitted still weighs no
234+ // more than everything shown, which is what the line below is entitled to say.
235+ //
236+ // A rule that genuinely outweighs the rest still fills the list with its own repeats,
237+ // and that is the honest picture: fourteen occurrences of one word are where such a
238+ // document's number actually comes from. Whether the section should collapse them into
239+ // a count, as the observations section does, is a question about its shape rather than
240+ // about which evidence it drops.
241+ AppendBlock ( sb , text , ReportMessages . SignalsOrdered ) ;
242+ sb . AppendLine ( ) ;
243+ foreach ( var f in result . Signals
244+ . GroupBy ( f => f . RuleId )
245+ . SelectMany ( g => g . OrderBy ( f => f . Span . Start )
246+ . Select ( ( f , rank ) => ( Finding : f , Rank : rank ) ) )
247+ . OrderByDescending ( x => x . Finding . Weight )
248+ . ThenBy ( x => x . Rank )
249+ . ThenBy ( x => x . Finding . Span . Start )
250+ . Select ( x => x . Finding )
251+ . Take ( o . MaxRows ) )
203252 {
204253 sb . Append ( "- **" ) . Append ( f . Category ) . Append ( "** — " ) ;
205254 if ( ! string . IsNullOrWhiteSpace ( f . MatchedText ) )
@@ -209,7 +258,10 @@ public static string ToMarkdown(AnalysisResult result, ReportOptions? options =
209258 if ( result . Signals . Count > o . MaxRows )
210259 {
211260 sb . AppendLine ( ) ;
212- AppendBlock ( sb , text , ReportMessages . MoreRows , result . Signals . Count - o . MaxRows ) ;
261+ // Not the generic "… and N more": what was cut is now a property of the evidence
262+ // rather than of where it happened to fall, and the reader is entitled to know that
263+ // nothing stronger than what they are looking at was left out.
264+ AppendBlock ( sb , text , ReportMessages . SignalsMore , result . Signals . Count - o . MaxRows ) ;
213265 }
214266 }
215267 sb . AppendLine ( ) ;
@@ -220,11 +272,23 @@ public static string ToMarkdown(AnalysisResult result, ReportOptions? options =
220272 sb . AppendLine ( ) ;
221273 AppendBlock ( sb , text , ReportMessages . ObservationsIntro ) ;
222274 sb . AppendLine ( ) ;
223- foreach ( var group in result . Observations . GroupBy ( f => f . RuleId ) . Take ( o . MaxRows ) )
275+ // Most frequent first, for the same reason as the signals list: a rule used thirty times
276+ // is the one the reader wants to see, and in rule-id order it can be pushed out by
277+ // twenty rules that fired once. The id comes from a rule pack, which is JSON anyone can
278+ // contribute, so it goes through Cell like any other text this report did not write.
279+ var groups = result . Observations . GroupBy ( f => f . RuleId )
280+ . OrderByDescending ( g => g . Count ( ) ) . ThenBy ( g => g . Key , StringComparer . Ordinal )
281+ . ToList ( ) ;
282+ foreach ( var group in groups . Take ( o . MaxRows ) )
224283 AppendBlock ( sb , text , group . Count ( ) == 1
225284 ? ReportMessages . ObservationsRowOne
226285 : ReportMessages . ObservationsRowOther ,
227- group . Key , group . Count ( ) ) ;
286+ Cell ( group . Key ) , group . Count ( ) ) ;
287+ if ( groups . Count > o . MaxRows )
288+ {
289+ sb . AppendLine ( ) ;
290+ AppendBlock ( sb , text , ReportMessages . MoreRows , groups . Count - o . MaxRows ) ;
291+ }
228292 sb . AppendLine ( ) ;
229293 }
230294
@@ -293,7 +357,7 @@ public static string FolderToMarkdown(
293357 var unreadable = entries . Where ( e => e . Error is not null ) . ToList ( ) ;
294358 var scored = entries . Where ( e => e . Error is null && e . Score is not null ) . ToList ( ) ;
295359
296- sb . Append ( "# " ) . AppendLine ( title ) ;
360+ sb . Append ( "# " ) . AppendLine ( Cell ( title ) ) ;
297361 sb . AppendLine ( ) ;
298362 var fallbackNoticeAt = sb . Length ;
299363 AppendBlock ( sb , text , ReportMessages . MetaFolder , Cell ( folderName ) ) ;
@@ -596,19 +660,35 @@ private static string Pct(double fraction) =>
596660 CultureInfo . InvariantCulture ) + "%" ;
597661
598662 /// <summary>
599- /// User content on its way into a Markdown line. Two things it must survive being given: a pipe,
663+ /// User content on its way into a Markdown line. Three things it must survive being given: a pipe,
600664 /// which would open an extra table cell and shift every number one column to the right in a table
601- /// a teacher reads scores from; and a newline, which would end the list item and let whatever
665+ /// a teacher reads scores from; a newline, which would end the list item and let whatever
602666 /// followed become report prose — a line beginning "## " arrived as a heading, in the report's own
603- /// voice, from a filename or an extractor's error message.
667+ /// voice, from a filename or an extractor's error message; and a <c><</c>.
668+ ///
669+ /// The last one is why the Markdown form needs escaping at all. <see cref="ToHtml"/> escapes on
670+ /// its way out, so the HTML was never at risk — but Markdown is the form this file documents for
671+ /// pasting into an LMS comment box or a GitHub issue, and both of those render raw HTML embedded
672+ /// in Markdown. A document containing <c><img src=x onerror=…></c> reached them intact, and
673+ /// the person pasting it is a teacher who has been told the report is the safe thing to forward.
674+ /// Backslash rather than an entity, so the character survives one escaping and exactly one:
675+ /// <see cref="Inline"/> undoes it before escaping for HTML, and the reader sees what the document
676+ /// actually said. Reproducing the matched text exactly is the claim this product rests on.
604677 ///
605- /// Matched text is user content by definition, and so is anything a community rule pack matches,
606- /// which is JSON anybody can contribute.
678+ /// Matched text is user content by definition, and so is anything a community rule pack matches
679+ /// or names, which is JSON anybody can contribute.
607680 /// </summary>
608681 private static string Cell ( string ? text ) =>
609682 string . IsNullOrEmpty ( text )
610683 ? ""
611- : text . ReplaceLineEndings ( " " ) . Replace ( "|" , "\\ |" ) . Trim ( ) ;
684+ // The backslash goes first, and it is not decoration. Escaping only the bracket turns a
685+ // document that already contains \< into \\< , which Markdown reads as an escaped
686+ // backslash followed by a live bracket — the escape defeated with one extra character.
687+ : text . ReplaceLineEndings ( " " )
688+ . Replace ( "\\ " , "\\ \\ " )
689+ . Replace ( "|" , "\\ |" )
690+ . Replace ( "<" , "\\ <" )
691+ . Trim ( ) ;
612692
613693 private static string Escape ( string s ) =>
614694 s . Replace ( "&" , "&" ) . Replace ( "<" , "<" ) . Replace ( ">" , ">" ) ;
@@ -716,13 +796,41 @@ private static List<string> SplitRow(string line)
716796 /// <summary>Bold, italic and code, applied after escaping so a document cannot inject markup.</summary>
717797 private static string Inline ( string text )
718798 {
719- var s = Escape ( text ) ;
799+ var s = Escape ( Unescape ( text ) ) ;
720800 s = Wrap ( s , "**" , "<strong>" , "</strong>" ) ;
721801 s = Wrap ( s , "`" , "<code>" , "</code>" ) ;
722802 s = Wrap ( s , "*" , "<em>" , "</em>" ) ;
723803 return s ;
724804 }
725805
806+ /// <summary>
807+ /// Undoes what <see cref="Cell"/> wrote, so the HTML shows the character and not the backslash
808+ /// that protected it in the Markdown. Only the three escapes this file emits: a backslash before
809+ /// anything else came from the document and stays.
810+ ///
811+ /// Table cells already lose their <c>\|</c> in <see cref="SplitRow"/>, which has to resolve them
812+ /// before it can tell a real column boundary from a pipe inside a filename; the list items and
813+ /// paragraphs had no such step, so a citation message containing a pipe used to reach the HTML
814+ /// page as <c>\|</c>.
815+ /// </summary>
816+ private static string Unescape ( string text )
817+ {
818+ if ( ! text . Contains ( '\\ ' ) ) return text ;
819+
820+ var sb = new StringBuilder ( text . Length ) ;
821+ for ( int i = 0 ; i < text . Length ; i ++ )
822+ {
823+ if ( text [ i ] == '\\ ' && i + 1 < text . Length
824+ && ( text [ i + 1 ] is '|' or '<' or '\\ ' ) )
825+ {
826+ sb . Append ( text [ ++ i ] ) ;
827+ continue ;
828+ }
829+ sb . Append ( text [ i ] ) ;
830+ }
831+ return sb . ToString ( ) ;
832+ }
833+
726834 private static string Wrap ( string text , string marker , string open , string close )
727835 {
728836 var parts = text . Split ( marker ) ;
@@ -762,6 +870,12 @@ public sealed record ReportOptions
762870 /// <summary>
763871 /// Where each list stops. A report meant to be read by a person is worth less at four hundred rows
764872 /// than at forty, and the count of what was left out is printed rather than the rows themselves.
873+ ///
874+ /// Every list that this cuts is sorted strongest first before it is cut, and every one of them
875+ /// says how many it left out. Truncating a list the analyser returns in document order silently
876+ /// discards the evidence with the most claim to be on the page — see the signals section. The
877+ /// folder table is deliberately exempt and prints every file: a scan of two hundred essays that
878+ /// omitted the low scorers would be withholding the result that settles a suspicion.
765879 /// </summary>
766880 public int MaxRows { get ; init ; } = 40 ;
767881
0 commit comments