mirror of
https://github.com/conorbronsdon/avoid-ai-writing.git
synced 2026-09-19 01:32:11 +08:00
14a7087452
Co-authored-by: Conor Bronsdon <conorbronsdon@users.noreply.github.com>
572 lines
18 KiB
JavaScript
572 lines
18 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Structural preprocessing for the false-positive measurement harness.
|
|
*
|
|
* Markdown structure is kept where the detector can observe it. Only ordinary
|
|
* hard-wrapped prose is folded. Paragraph eligibility is deliberately a
|
|
* separate final step so skipped candidates remain available for diagnostics.
|
|
*/
|
|
|
|
const MIN_WORDS = 50;
|
|
const MAX_WORDS = 400;
|
|
|
|
const ATX = /^ {0,3}#{1,6}(?:[ \t]+|$)/;
|
|
const FENCE = /^[ \t]{0,3}(`{3,}|~{3,})(.*)$/;
|
|
const BULLET_LIST = /^[ \t]*[-+*•][ \t]+/;
|
|
const ORDERED_LIST = /^[ \t]*(\d+)[.)][ \t]+/;
|
|
const LIST = /^[ \t]*(?:[-+*•]|\d+[.)])[ \t]+/;
|
|
const LIST_CONTINUATION = /^(?: {2,}|\t)\S/;
|
|
const QUOTE = /^[ \t]*>/;
|
|
const INDENTED = /^(?: {4}|\t)/;
|
|
const SETEXT = /^ {0,3}(?:=+|-+)[ \t]*$/;
|
|
const THEMATIC = /^ {0,3}(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/;
|
|
|
|
function wordCount(text) {
|
|
return (text.match(/\S+/g) || []).length;
|
|
}
|
|
|
|
function scanLines(input) {
|
|
const lines = [];
|
|
let start = 0;
|
|
let i = 0;
|
|
|
|
while (i < input.length) {
|
|
if (input[i] !== '\r' && input[i] !== '\n') {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
const newlineEnd = input[i] === '\r' && input[i + 1] === '\n' ? i + 2 : i + 1;
|
|
lines.push({ text: input.slice(start, i), start, end: i, fullEnd: newlineEnd });
|
|
start = newlineEnd;
|
|
i = newlineEnd;
|
|
}
|
|
|
|
if (start < input.length || input.length === 0) {
|
|
lines.push({ text: input.slice(start), start, end: input.length, fullEnd: input.length });
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
function isBlank(line) {
|
|
return /^[ \t]*$/.test(line.text);
|
|
}
|
|
|
|
function firstLetterCase(text) {
|
|
const match = text.match(/\p{L}/u);
|
|
if (!match) return null;
|
|
const letter = match[0];
|
|
if (letter.toLocaleUpperCase() === letter.toLocaleLowerCase()) return null;
|
|
return letter === letter.toLocaleLowerCase() ? 'lower' : 'upper';
|
|
}
|
|
|
|
function isColonCandidate(text) {
|
|
const trimmed = text.trim();
|
|
return trimmed.endsWith(':') && wordCount(trimmed) <= 20;
|
|
}
|
|
|
|
function addKind(kinds, kind) {
|
|
if (!kinds.includes(kind)) kinds.push(kind);
|
|
}
|
|
|
|
function potentialIndentedCodeLines(lines) {
|
|
const result = lines.map(() => false);
|
|
for (let i = 0; i < lines.length; i++) {
|
|
result[i] = !isBlank(lines[i])
|
|
&& INDENTED.test(lines[i].text)
|
|
&& (i === 0 || isBlank(lines[i - 1]) || result[i - 1]);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// An ordered marker other than numeric 1 cannot interrupt an open paragraph.
|
|
// This narrow distinction follows https://spec.commonmark.org/0.31.2/#list-items
|
|
// and keeps a hard-wrapped year such as `1859.` in prose.
|
|
function startsListRun(lines, kinds, indentedCode, index) {
|
|
if (BULLET_LIST.test(lines[index].text)) return true;
|
|
const ordered = lines[index].text.match(ORDERED_LIST);
|
|
if (!ordered) return false;
|
|
if (Number(ordered[1]) === 1 || index === 0 || isBlank(lines[index - 1])) return true;
|
|
const previousKinds = kinds[index - 1];
|
|
return previousKinds.length > 0
|
|
|| QUOTE.test(lines[index - 1].text)
|
|
|| indentedCode[index - 1];
|
|
}
|
|
|
|
/** Classify lines without changing their source offsets. */
|
|
function classify(lines) {
|
|
const kinds = lines.map(() => []);
|
|
const fences = new Map();
|
|
const indentedCode = potentialIndentedCodeLines(lines);
|
|
|
|
// A fence closes only with the same marker and at least the opener length.
|
|
// Different or shorter markers inside it are content. An unclosed fence owns
|
|
// the remainder of the input.
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (kinds[i].length || isBlank(lines[i])) continue;
|
|
const opener = lines[i].text.match(FENCE);
|
|
if (!opener) continue;
|
|
const marker = opener[1][0];
|
|
// CommonMark forbids backticks in the info string of a backtick fence.
|
|
// Without this guard, an inline span such as ```js``` consumes the rest of
|
|
// the document as an unclosed fenced block.
|
|
if (marker === '`' && opener[2].includes('`')) continue;
|
|
const length = opener[1].length;
|
|
let end = lines.length - 1;
|
|
for (let j = i + 1; j < lines.length; j++) {
|
|
const close = lines[j].text.match(/^[ \t]{0,3}(`+|~+)[ \t]*$/);
|
|
if (close && close[1][0] === marker && close[1].length >= length) {
|
|
end = j;
|
|
break;
|
|
}
|
|
}
|
|
for (let j = i; j <= end; j++) addKind(kinds[j], 'fenced-code');
|
|
fences.set(i, end);
|
|
i = end;
|
|
}
|
|
|
|
// Explicit headings take priority over the punctuation rules below.
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (kinds[i].includes('fenced-code') || isBlank(lines[i])) continue;
|
|
if (ATX.test(lines[i].text)) addKind(kinds[i], 'atx-heading');
|
|
}
|
|
|
|
// A dash line is a setext underline when it follows eligible title text;
|
|
// otherwise the same shape is a thematic break.
|
|
const startsNonEmptyList = (index) => {
|
|
const marker = index >= 0 ? lines[index].text.match(LIST) : null;
|
|
return Boolean(marker
|
|
&& /\S/.test(lines[index].text.slice(marker[0].length))
|
|
&& startsListRun(lines, kinds, indentedCode, index));
|
|
};
|
|
const startsOpenQuoteParagraph = (index) => {
|
|
if (index < 0 || !QUOTE.test(lines[index].text)) return false;
|
|
const content = lines[index].text.replace(/^(?:[ \t]*>[ \t]?)+/, '');
|
|
const listMarker = content.match(LIST);
|
|
if (listMarker) return /\S/.test(content.slice(listMarker[0].length));
|
|
return /\S/.test(content)
|
|
&& !ATX.test(content)
|
|
&& !FENCE.test(content)
|
|
&& !THEMATIC.test(content)
|
|
&& !QUOTE.test(content);
|
|
};
|
|
const startsContainer = (index) => index >= 0
|
|
&& (startsOpenQuoteParagraph(index) || startsNonEmptyList(index));
|
|
const followsLooseList = (index) => {
|
|
if (index <= 0 || !LIST_CONTINUATION.test(lines[index].text) || !isBlank(lines[index - 1])) return false;
|
|
let previous = index - 1;
|
|
while (previous >= 0) {
|
|
if (isBlank(lines[previous])) {
|
|
previous -= 1;
|
|
continue;
|
|
}
|
|
if (kinds[previous].length) return false;
|
|
if (startsNonEmptyList(previous)) return true;
|
|
if (!LIST_CONTINUATION.test(lines[previous].text)) return false;
|
|
previous -= 1;
|
|
}
|
|
return false;
|
|
};
|
|
const setextBoundary = (index) => index < 0
|
|
|| kinds[index].length > 0
|
|
|| isBlank(lines[index])
|
|
|| ATX.test(lines[index].text)
|
|
|| QUOTE.test(lines[index].text)
|
|
|| startsNonEmptyList(index)
|
|
|| THEMATIC.test(lines[index].text);
|
|
for (let underline = 1; underline < lines.length; underline++) {
|
|
if (kinds[underline].length || !SETEXT.test(lines[underline].text)) continue;
|
|
if (setextBoundary(underline - 1)) continue;
|
|
let start = underline - 1;
|
|
while (!setextBoundary(start - 1)) start -= 1;
|
|
if (startsContainer(start - 1)) continue;
|
|
if (followsLooseList(start)) continue;
|
|
// Indented code cannot start a paragraph, but indentation after paragraph
|
|
// text is lazy continuation and remains part of a multiline heading.
|
|
while (start < underline && INDENTED.test(lines[start].text)) start += 1;
|
|
if (start === underline) continue;
|
|
for (let i = start; i <= underline; i++) addKind(kinds[i], 'setext-heading');
|
|
}
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (!kinds[i].length && THEMATIC.test(lines[i].text)) addKind(kinds[i], 'thematic-break');
|
|
}
|
|
|
|
// Mark complete list runs, including lazy continuation and blank-separated
|
|
// subsequent items or indented continuation. This keeps line structure and
|
|
// prevents continuation text from being folded as an unrelated paragraph.
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (kinds[i].length || !startsListRun(lines, kinds, indentedCode, i)) continue;
|
|
let j = i;
|
|
while (j < lines.length) {
|
|
if (kinds[j].some((kind) => kind.endsWith('heading') || kind === 'fenced-code')) break;
|
|
if (QUOTE.test(lines[j].text) || kinds[j].includes('thematic-break')) break;
|
|
if (!isBlank(lines[j])) addKind(kinds[j], 'list');
|
|
if (!isBlank(lines[j])) {
|
|
j += 1;
|
|
continue;
|
|
}
|
|
|
|
let next = j;
|
|
while (next < lines.length && isBlank(lines[next])) next += 1;
|
|
if (next < lines.length && !kinds[next].length && (LIST.test(lines[next].text) || LIST_CONTINUATION.test(lines[next].text))) {
|
|
j = next;
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
i = Math.max(i, j - 1);
|
|
}
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (kinds[i].length || isBlank(lines[i])) continue;
|
|
if (QUOTE.test(lines[i].text)) addKind(kinds[i], 'blockquote');
|
|
else if (
|
|
INDENTED.test(lines[i].text)
|
|
&& (i === 0 || isBlank(lines[i - 1]) || kinds[i - 1].includes('indented-code'))
|
|
) addKind(kinds[i], 'indented-code');
|
|
}
|
|
|
|
// Colon headings are intentionally narrow. At the beginning of a prose
|
|
// block, an immediate lowercase continuation makes the whole run prose. A
|
|
// short standalone block may introduce the next block regardless of case.
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (kinds[i].length || isBlank(lines[i]) || !isColonCandidate(lines[i].text)) continue;
|
|
const atBlockStart = i === 0 || isBlank(lines[i - 1]) || isHeadingKinds(kinds[i - 1]);
|
|
if (!atBlockStart) continue;
|
|
|
|
const next = i + 1;
|
|
const standalone = next >= lines.length || isBlank(lines[next]);
|
|
if (!standalone) {
|
|
if (kinds[next].length || firstLetterCase(lines[next].text) !== 'upper') continue;
|
|
}
|
|
addKind(kinds[i], 'colon-inferred');
|
|
}
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (!kinds[i].length && !isBlank(lines[i])) addKind(kinds[i], 'prose');
|
|
}
|
|
|
|
return { kinds, fences };
|
|
}
|
|
|
|
function isHeadingKinds(kinds) {
|
|
return kinds.includes('atx-heading') || kinds.includes('setext-heading') || kinds.includes('colon-inferred');
|
|
}
|
|
|
|
function headingKind(kinds) {
|
|
if (kinds.includes('atx-heading')) return 'atx';
|
|
if (kinds.includes('setext-heading')) return 'setext';
|
|
if (kinds.includes('colon-inferred')) return 'colon-inferred';
|
|
return null;
|
|
}
|
|
|
|
function hasHardBreak(text) {
|
|
return /(?: {2,}|\\)$/.test(text);
|
|
}
|
|
|
|
function normalizedRange(lines, classified, start, end, preserveTerminalNewline = false) {
|
|
let output = '';
|
|
let previousJoined = false;
|
|
for (let i = start; i < end; i++) {
|
|
const line = lines[i];
|
|
const ordinary = classified.kinds[i].length === 1 && classified.kinds[i][0] === 'prose';
|
|
const nextOrdinary = i + 1 < end
|
|
&& classified.kinds[i + 1].length === 1
|
|
&& classified.kinds[i + 1][0] === 'prose';
|
|
const joinNext = ordinary && nextOrdinary && !isBlank(line) && !isBlank(lines[i + 1]) && !hasHardBreak(line.text);
|
|
|
|
let value = line.text;
|
|
if (previousJoined) value = value.replace(/^[ \t]+/, '');
|
|
if (joinNext) value = value.replace(/[ \t]+$/, '');
|
|
output += value;
|
|
|
|
if (i + 1 < end) output += joinNext ? ' ' : '\n';
|
|
else if (preserveTerminalNewline && line.fullEnd > line.end) output += '\n';
|
|
previousJoined = joinNext;
|
|
}
|
|
return output;
|
|
}
|
|
|
|
function sourceSpan(lines, start, end) {
|
|
return {
|
|
start: lines[start].start,
|
|
end: lines[end - 1].end,
|
|
};
|
|
}
|
|
|
|
function collectKinds(classified, start, end) {
|
|
const result = [];
|
|
for (let i = start; i < end; i++) {
|
|
for (const kind of classified.kinds[i]) addKind(result, kind);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function nextBlockEnd(lines, classified, start) {
|
|
let end = start;
|
|
while (end < lines.length) {
|
|
if (isBlank(lines[end]) || isHeadingKinds(classified.kinds[end]) || classified.fences.has(end)) break;
|
|
end += 1;
|
|
}
|
|
return end;
|
|
}
|
|
|
|
/** Build heading and body atoms before applying eligibility filters. */
|
|
function buildAtoms(lines, classified) {
|
|
const atoms = [];
|
|
let i = 0;
|
|
|
|
while (i < lines.length) {
|
|
if (isBlank(lines[i])) {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
const lineKinds = classified.kinds[i];
|
|
if (isHeadingKinds(lineKinds)) {
|
|
let end = i + 1;
|
|
if (lineKinds.includes('setext-heading')) {
|
|
while (end < lines.length && classified.kinds[end].includes('setext-heading')) {
|
|
const isUnderline = SETEXT.test(lines[end].text);
|
|
end += 1;
|
|
if (isUnderline) break;
|
|
}
|
|
}
|
|
const kinds = collectKinds(classified, i, Math.min(end, lines.length));
|
|
atoms.push({
|
|
startLine: i,
|
|
endLine: Math.min(end, lines.length),
|
|
kinds,
|
|
isHeading: true,
|
|
headingKind: headingKind(kinds),
|
|
});
|
|
i = end;
|
|
continue;
|
|
}
|
|
|
|
if (classified.fences.has(i)) {
|
|
const end = classified.fences.get(i) + 1;
|
|
atoms.push({
|
|
startLine: i,
|
|
endLine: end,
|
|
kinds: ['fenced-code'],
|
|
isHeading: false,
|
|
headingKind: null,
|
|
});
|
|
i = end;
|
|
continue;
|
|
}
|
|
|
|
const start = i;
|
|
let mergedContinuation = false;
|
|
let sawList = false;
|
|
let sawQuote = false;
|
|
let sawIndented = false;
|
|
let sawProse = false;
|
|
while (i < lines.length) {
|
|
if (isHeadingKinds(classified.kinds[i]) || classified.fences.has(i)) break;
|
|
if (classified.kinds[i].includes('list')) sawList = true;
|
|
if (classified.kinds[i].includes('blockquote')) sawQuote = true;
|
|
if (classified.kinds[i].includes('indented-code')) sawIndented = true;
|
|
if (classified.kinds[i].includes('prose')) sawProse = true;
|
|
if (!isBlank(lines[i])) {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
let next = i;
|
|
while (next < lines.length && isBlank(lines[next])) next += 1;
|
|
const oneBlankLine = next === i + 1;
|
|
const structuralContinuation = oneBlankLine && next < lines.length && (
|
|
(sawList && classified.kinds[next].includes('list'))
|
|
|| (sawQuote && classified.kinds[next].includes('blockquote'))
|
|
|| (sawIndented && classified.kinds[next].includes('indented-code'))
|
|
|| (sawProse && !sawList && classified.kinds[next].includes('list'))
|
|
);
|
|
const prospectiveEnd = structuralContinuation ? nextBlockEnd(lines, classified, next) : next;
|
|
const mergeFits = structuralContinuation
|
|
&& wordCount(normalizedRange(lines, classified, start, prospectiveEnd)) <= MAX_WORDS;
|
|
if (mergeFits) {
|
|
mergedContinuation = true;
|
|
i = next;
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
|
|
atoms.push({
|
|
startLine: start,
|
|
endLine: i,
|
|
kinds: collectKinds(classified, start, i),
|
|
isHeading: false,
|
|
headingKind: null,
|
|
mergedContinuation,
|
|
});
|
|
}
|
|
return atoms;
|
|
}
|
|
|
|
function makeDecision(text, spans, kinds, attached, attachedKind, reasonOverride, mergedContinuation = false) {
|
|
const inputWords = wordCount(text);
|
|
let status = 'selected';
|
|
let reason = null;
|
|
|
|
if (reasonOverride) {
|
|
status = 'skipped';
|
|
reason = reasonOverride;
|
|
} else if (inputWords < MIN_WORDS) {
|
|
status = 'skipped';
|
|
reason = 'below-min';
|
|
} else if (inputWords > MAX_WORDS) {
|
|
status = 'skipped';
|
|
reason = 'above-max';
|
|
}
|
|
|
|
return {
|
|
text,
|
|
spans,
|
|
kinds,
|
|
headingAttached: attached,
|
|
headingKind: attachedKind,
|
|
...(mergedContinuation ? { mergedContinuation: true } : {}),
|
|
inputWords,
|
|
status,
|
|
reason,
|
|
};
|
|
}
|
|
|
|
function shortHeadingReason(text, reason) {
|
|
return wordCount(text) < MIN_WORDS ? reason : null;
|
|
}
|
|
|
|
function decisionsForParagraphs(input, lines, classified) {
|
|
const atoms = buildAtoms(lines, classified);
|
|
const decisions = [];
|
|
|
|
for (let i = 0; i < atoms.length; i++) {
|
|
const atom = atoms[i];
|
|
if (atom.isHeading) {
|
|
const next = atoms[i + 1];
|
|
if (next && !next.isHeading) {
|
|
const headingText = normalizedRange(lines, classified, atom.startLine, atom.endLine);
|
|
const bodyText = normalizedRange(lines, classified, next.startLine, next.endLine);
|
|
const separator = input
|
|
.slice(lines[atom.endLine - 1].end, lines[next.startLine].start)
|
|
.replace(/\r\n|\r|\n/g, '\n');
|
|
const combined = headingText + separator + bodyText;
|
|
const combinedWords = wordCount(combined);
|
|
|
|
if (combinedWords <= MAX_WORDS) {
|
|
const kinds = [...atom.kinds];
|
|
for (const kind of next.kinds) addKind(kinds, kind);
|
|
decisions.push(makeDecision(
|
|
combined,
|
|
[sourceSpan(lines, atom.startLine, atom.endLine), sourceSpan(lines, next.startLine, next.endLine)],
|
|
kinds,
|
|
true,
|
|
atom.headingKind,
|
|
null,
|
|
next.mergedContinuation,
|
|
));
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
decisions.push(makeDecision(
|
|
headingText,
|
|
[sourceSpan(lines, atom.startLine, atom.endLine)],
|
|
atom.kinds,
|
|
false,
|
|
atom.headingKind,
|
|
shortHeadingReason(headingText, 'heading-would-exceed-maximum'),
|
|
));
|
|
continue;
|
|
}
|
|
|
|
const headingText = normalizedRange(lines, classified, atom.startLine, atom.endLine);
|
|
decisions.push(makeDecision(
|
|
headingText,
|
|
[sourceSpan(lines, atom.startLine, atom.endLine)],
|
|
atom.kinds,
|
|
false,
|
|
atom.headingKind,
|
|
shortHeadingReason(headingText, 'unattached-heading'),
|
|
));
|
|
continue;
|
|
}
|
|
|
|
decisions.push(makeDecision(
|
|
normalizedRange(lines, classified, atom.startLine, atom.endLine),
|
|
[sourceSpan(lines, atom.startLine, atom.endLine)],
|
|
atom.kinds,
|
|
false,
|
|
null,
|
|
null,
|
|
atom.mergedContinuation,
|
|
));
|
|
}
|
|
|
|
return decisions;
|
|
}
|
|
|
|
/**
|
|
* Normalize and select measurement units.
|
|
*
|
|
* Spans always refer to UTF-16 offsets in the original input. Document mode
|
|
* has one selected decision and intentionally bypasses paragraph eligibility.
|
|
*/
|
|
function prepareUnits(text, mode = 'paragraph') {
|
|
if (typeof text !== 'string') throw new TypeError('text must be a string');
|
|
if (mode !== 'paragraph' && mode !== 'document') throw new RangeError(`unknown unit mode: ${mode}`);
|
|
|
|
const lines = scanLines(text);
|
|
const classified = classify(lines);
|
|
const normalizedText = normalizedRange(lines, classified, 0, lines.length, true);
|
|
|
|
if (mode === 'document') {
|
|
return {
|
|
normalizedText,
|
|
decisions: [{
|
|
text: normalizedText,
|
|
spans: [{ start: 0, end: text.length }],
|
|
kinds: collectKinds(classified, 0, lines.length),
|
|
headingAttached: false,
|
|
headingKind: null,
|
|
inputWords: wordCount(normalizedText),
|
|
status: 'selected',
|
|
reason: null,
|
|
}],
|
|
};
|
|
}
|
|
|
|
return {
|
|
normalizedText,
|
|
decisions: decisionsForParagraphs(text, lines, classified),
|
|
};
|
|
}
|
|
|
|
function normalizeUnit(text) {
|
|
return prepareUnits(text, 'document').normalizedText;
|
|
}
|
|
|
|
function splitUnits(text) {
|
|
return prepareUnits(text, 'paragraph').decisions
|
|
.filter((decision) => decision.status === 'selected')
|
|
.map((decision) => decision.text);
|
|
}
|
|
|
|
function unitsForText(text, mode = 'paragraph') {
|
|
return prepareUnits(text, mode).decisions
|
|
.filter((decision) => decision.status === 'selected')
|
|
.map((decision) => decision.text);
|
|
}
|
|
|
|
module.exports = {
|
|
MIN_WORDS,
|
|
MAX_WORDS,
|
|
prepareUnits,
|
|
normalizeUnit,
|
|
splitUnits,
|
|
unitsForText,
|
|
};
|