@dzhechkov/harness-core 0.8.2 → 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dz-manifest.json +146 -58
- package/README.md +72 -2
- package/dist/cmd-usage.d.ts +148 -0
- package/dist/cmd-usage.d.ts.map +1 -0
- package/dist/cmd-usage.js +548 -0
- package/dist/cmd-usage.js.map +1 -0
- package/dist/compounding.d.ts +4 -0
- package/dist/compounding.d.ts.map +1 -1
- package/dist/compounding.js +6 -0
- package/dist/compounding.js.map +1 -1
- package/dist/contract-checklist.d.ts +123 -0
- package/dist/contract-checklist.d.ts.map +1 -0
- package/dist/contract-checklist.js +700 -0
- package/dist/contract-checklist.js.map +1 -0
- package/dist/feature-adr-checkpoints.d.ts +11 -2
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +37 -2
- package/dist/feature-adr-checkpoints.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +58 -23
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +208 -59
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +25 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +59 -1
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.js +8 -8
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/loop-plan.d.ts +13 -1
- package/dist/loop-plan.d.ts.map +1 -1
- package/dist/loop-plan.js +15 -1
- package/dist/loop-plan.js.map +1 -1
- package/dist/loop-render.d.ts.map +1 -1
- package/dist/loop-render.js +51 -6
- package/dist/loop-render.js.map +1 -1
- package/dist/loop-trace.d.ts +20 -1
- package/dist/loop-trace.d.ts.map +1 -1
- package/dist/loop-trace.js +83 -1
- package/dist/loop-trace.js.map +1 -1
- package/dist/model-recommender.d.ts +8 -0
- package/dist/model-recommender.d.ts.map +1 -1
- package/dist/model-recommender.js +31 -4
- package/dist/model-recommender.js.map +1 -1
- package/dist/qe-bridge.d.ts.map +1 -1
- package/dist/qe-bridge.js +9 -0
- package/dist/qe-bridge.js.map +1 -1
- package/dist/restart-advisor.d.ts +103 -0
- package/dist/restart-advisor.d.ts.map +1 -0
- package/dist/restart-advisor.js +445 -0
- package/dist/restart-advisor.js.map +1 -0
- package/dist/slop-lint.d.ts +128 -0
- package/dist/slop-lint.d.ts.map +1 -0
- package/dist/slop-lint.js +607 -0
- package/dist/slop-lint.js.map +1 -0
- package/dist/workflow-run.d.ts.map +1 -1
- package/dist/workflow-run.js +18 -12
- package/dist/workflow-run.js.map +1 -1
- package/package.json +19 -15
- package/sbom.json +277 -57
- package/src/cmd-usage.ts +720 -0
- package/src/compounding.ts +13 -0
- package/src/contract-checklist.ts +973 -0
- package/src/deadwood-allowlist.json +80 -0
- package/src/feature-adr-checkpoints.ts +38 -2
- package/src/feature-adr-routing.ts +238 -55
- package/src/guard.ts +79 -1
- package/src/index.ts +81 -1
- package/src/loop-blobs.generated.ts +8 -8
- package/src/loop-plan.ts +36 -3
- package/src/loop-render.ts +50 -6
- package/src/loop-trace.ts +91 -2
- package/src/model-recommender.ts +35 -4
- package/src/qe-bridge.ts +9 -0
- package/src/restart-advisor.ts +579 -0
- package/src/slop-lint.ts +762 -0
- package/src/slop-markers.json +71 -0
- package/src/workflow-run.ts +18 -11
package/src/slop-lint.ts
ADDED
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
export type SlopLanguage = 'en' | 'ru';
|
|
4
|
+
export type SlopRuleId = 'lexical-density' | 'bullet-wall' | 'triple-adjective-stack';
|
|
5
|
+
|
|
6
|
+
export interface SlopValidationError {
|
|
7
|
+
readonly field: string;
|
|
8
|
+
readonly value: unknown;
|
|
9
|
+
readonly reason: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type ValidationResult<T> =
|
|
13
|
+
| { readonly ok: true; readonly value: T }
|
|
14
|
+
| { readonly ok: false; readonly errors: readonly SlopValidationError[] };
|
|
15
|
+
|
|
16
|
+
export interface SlopDiagnostic {
|
|
17
|
+
readonly code: 'unclosed-example-block' | 'input-limit-exceeded' | 'analysis-error';
|
|
18
|
+
readonly line: number;
|
|
19
|
+
readonly message: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SlopRegistryEntry {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly language: SlopLanguage;
|
|
25
|
+
readonly match: {
|
|
26
|
+
readonly kind: 'form' | 'stem' | 'phrase';
|
|
27
|
+
readonly values: readonly string[];
|
|
28
|
+
};
|
|
29
|
+
readonly rationale: string;
|
|
30
|
+
readonly provenance: {
|
|
31
|
+
readonly source: string;
|
|
32
|
+
readonly license: 'repository-authored';
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SlopRegistry {
|
|
37
|
+
readonly schema: 'dz-slop-registry/1';
|
|
38
|
+
readonly metadata: {
|
|
39
|
+
readonly policyVersion: string;
|
|
40
|
+
readonly owner: string;
|
|
41
|
+
readonly reviewCadence: string;
|
|
42
|
+
readonly englishReference: {
|
|
43
|
+
readonly repository: string;
|
|
44
|
+
readonly commit: string;
|
|
45
|
+
readonly path: string;
|
|
46
|
+
readonly license: 'none-declared';
|
|
47
|
+
readonly retrieved: string;
|
|
48
|
+
readonly use: string;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
readonly markers: readonly SlopRegistryEntry[];
|
|
52
|
+
readonly adjectives: readonly SlopRegistryEntry[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SlopLintConfig {
|
|
56
|
+
readonly schema: 'dz-slop-config/1';
|
|
57
|
+
readonly lexicalDensityPer100Words: Readonly<{ en: number; ru: number }>;
|
|
58
|
+
readonly lexicalMinimumMarkers: number;
|
|
59
|
+
readonly lexicalWordFloor: number;
|
|
60
|
+
readonly bulletMinimumItems: number;
|
|
61
|
+
readonly bulletMinimumLineRatio: number;
|
|
62
|
+
readonly bulletMaximumMeanWords: number;
|
|
63
|
+
readonly adjectiveStackSize: 3;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface SlopEvidence {
|
|
67
|
+
readonly id: string;
|
|
68
|
+
readonly language: SlopLanguage;
|
|
69
|
+
readonly normalizedSpan: string;
|
|
70
|
+
readonly text: string;
|
|
71
|
+
readonly lineStart: number;
|
|
72
|
+
readonly columnStart: number;
|
|
73
|
+
readonly lineEnd: number;
|
|
74
|
+
readonly columnEnd: number;
|
|
75
|
+
readonly startOffset: number;
|
|
76
|
+
readonly endOffset: number;
|
|
77
|
+
readonly count: 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface SlopFindingMetrics {
|
|
81
|
+
readonly markerCount: number;
|
|
82
|
+
readonly distinctMarkers: number;
|
|
83
|
+
readonly visibleWords: number;
|
|
84
|
+
readonly densityPer100Words: number;
|
|
85
|
+
readonly listItems: number;
|
|
86
|
+
readonly listLineRatio: number;
|
|
87
|
+
readonly listMeanWords: number;
|
|
88
|
+
readonly adjectiveCount: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface SlopFindingThresholds {
|
|
92
|
+
readonly densityPer100Words: number;
|
|
93
|
+
readonly minimumMarkers: number;
|
|
94
|
+
readonly wordFloor: number;
|
|
95
|
+
readonly bulletMinimumItems: number;
|
|
96
|
+
readonly bulletMinimumLineRatio: number;
|
|
97
|
+
readonly bulletMaximumMeanWords: number;
|
|
98
|
+
readonly adjectiveStackSize: 3;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface SlopFinding {
|
|
102
|
+
readonly ruleId: SlopRuleId;
|
|
103
|
+
readonly severity: 'advisory';
|
|
104
|
+
readonly paragraph: number;
|
|
105
|
+
readonly lineStart: number;
|
|
106
|
+
readonly columnStart: number;
|
|
107
|
+
readonly lineEnd: number;
|
|
108
|
+
readonly columnEnd: number;
|
|
109
|
+
readonly language: SlopLanguage | 'mixed' | 'structural';
|
|
110
|
+
readonly excerpt: string;
|
|
111
|
+
readonly evidence: readonly SlopEvidence[];
|
|
112
|
+
readonly metrics: SlopFindingMetrics;
|
|
113
|
+
readonly thresholds: SlopFindingThresholds;
|
|
114
|
+
readonly suggestion: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface SlopLintResult {
|
|
118
|
+
readonly paragraphCount: number;
|
|
119
|
+
readonly findings: readonly SlopFinding[];
|
|
120
|
+
readonly diagnostics: readonly SlopDiagnostic[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const DEFAULT_SLOP_CONFIG: Readonly<SlopLintConfig> = Object.freeze({
|
|
124
|
+
schema: 'dz-slop-config/1',
|
|
125
|
+
lexicalDensityPer100Words: Object.freeze({ en: 4, ru: 4 }),
|
|
126
|
+
lexicalMinimumMarkers: 2,
|
|
127
|
+
lexicalWordFloor: 25,
|
|
128
|
+
bulletMinimumItems: 8,
|
|
129
|
+
bulletMinimumLineRatio: 0.8,
|
|
130
|
+
bulletMaximumMeanWords: 12,
|
|
131
|
+
adjectiveStackSize: 3,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
export const BUNDLED_SLOP_REGISTRY_URL = new URL('../src/slop-markers.json', import.meta.url);
|
|
135
|
+
|
|
136
|
+
const record = (value: unknown): Record<string, unknown> | null =>
|
|
137
|
+
value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
|
138
|
+
|
|
139
|
+
function finiteIn(value: unknown, min: number, max: number): boolean {
|
|
140
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function validateSlopLintConfig(value: unknown): ValidationResult<SlopLintConfig> {
|
|
144
|
+
const errors: SlopValidationError[] = [];
|
|
145
|
+
const root = record(value);
|
|
146
|
+
if (root === null) return { ok: false, errors: [{ field: '$', value, reason: 'must be an object' }] };
|
|
147
|
+
const need = (field: string, valid: boolean, reason: string): void => {
|
|
148
|
+
if (!valid) errors.push({ field, value: root[field], reason });
|
|
149
|
+
};
|
|
150
|
+
need('schema', root.schema === 'dz-slop-config/1', 'must equal dz-slop-config/1');
|
|
151
|
+
const density = record(root.lexicalDensityPer100Words);
|
|
152
|
+
if (density === null) {
|
|
153
|
+
errors.push({ field: 'lexicalDensityPer100Words', value: root.lexicalDensityPer100Words, reason: 'must contain complete en and ru thresholds' });
|
|
154
|
+
} else {
|
|
155
|
+
if (!finiteIn(density.en, 0.1, 100)) errors.push({ field: 'lexicalDensityPer100Words.en', value: density.en, reason: 'must be finite in 0.1..100' });
|
|
156
|
+
if (!finiteIn(density.ru, 0.1, 100)) errors.push({ field: 'lexicalDensityPer100Words.ru', value: density.ru, reason: 'must be finite in 0.1..100' });
|
|
157
|
+
}
|
|
158
|
+
need('lexicalMinimumMarkers', finiteIn(root.lexicalMinimumMarkers, 2, 100), 'must be finite in 2..100');
|
|
159
|
+
need('lexicalWordFloor', finiteIn(root.lexicalWordFloor, 1, 1_000), 'must be finite in 1..1000');
|
|
160
|
+
need('bulletMinimumItems', finiteIn(root.bulletMinimumItems, 3, 100), 'must be finite in 3..100');
|
|
161
|
+
need('bulletMinimumLineRatio', finiteIn(root.bulletMinimumLineRatio, 0.5, 1), 'must be finite in 0.5..1');
|
|
162
|
+
need('bulletMaximumMeanWords', finiteIn(root.bulletMaximumMeanWords, 1, 100), 'must be finite in 1..100');
|
|
163
|
+
need('adjectiveStackSize', root.adjectiveStackSize === 3, 'must equal 3');
|
|
164
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
165
|
+
return { ok: true, value: value as SlopLintConfig };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function validateRegistryEntry(
|
|
169
|
+
value: unknown,
|
|
170
|
+
field: string,
|
|
171
|
+
seen: Set<string>,
|
|
172
|
+
errors: SlopValidationError[],
|
|
173
|
+
): void {
|
|
174
|
+
const entry = record(value);
|
|
175
|
+
if (entry === null) {
|
|
176
|
+
errors.push({ field, value, reason: 'must be an object' });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const id = entry.id;
|
|
180
|
+
if (typeof id !== 'string' || !/^(?:en|ru)\.[a-z0-9.-]+$/.test(id)) {
|
|
181
|
+
errors.push({ field: `${field}.id`, value: id, reason: 'must be a stable en.* or ru.* id' });
|
|
182
|
+
} else if (seen.has(id)) {
|
|
183
|
+
errors.push({ field: `${field}.id`, value: id, reason: 'duplicate registry id' });
|
|
184
|
+
} else {
|
|
185
|
+
seen.add(id);
|
|
186
|
+
}
|
|
187
|
+
if (entry.language !== 'en' && entry.language !== 'ru') {
|
|
188
|
+
errors.push({ field: `${field}.language`, value: entry.language, reason: 'must be en or ru' });
|
|
189
|
+
} else if (typeof id === 'string' && !id.startsWith(`${entry.language}.`)) {
|
|
190
|
+
errors.push({ field: `${field}.language`, value: entry.language, reason: 'must match the id language prefix' });
|
|
191
|
+
}
|
|
192
|
+
const match = record(entry.match);
|
|
193
|
+
if (match === null || !['form', 'stem', 'phrase'].includes(String(match.kind))) {
|
|
194
|
+
errors.push({ field: `${field}.match`, value: entry.match, reason: 'must use form, stem, or phrase' });
|
|
195
|
+
} else if (!Array.isArray(match.values) || match.values.length === 0 ||
|
|
196
|
+
match.values.some((item) => typeof item !== 'string' || item.trim() === '')) {
|
|
197
|
+
errors.push({ field: `${field}.match.values`, value: match.values, reason: 'must contain non-empty literal strings' });
|
|
198
|
+
}
|
|
199
|
+
if (typeof entry.rationale !== 'string' || entry.rationale.trim() === '') {
|
|
200
|
+
errors.push({ field: `${field}.rationale`, value: entry.rationale, reason: 'must be non-empty' });
|
|
201
|
+
}
|
|
202
|
+
const provenance = record(entry.provenance);
|
|
203
|
+
if (provenance === null || typeof provenance.source !== 'string' || provenance.source.trim() === '' ||
|
|
204
|
+
provenance.license !== 'repository-authored') {
|
|
205
|
+
errors.push({ field: `${field}.provenance`, value: entry.provenance, reason: 'must name a source and repository-authored license' });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function parseSlopRegistry(value: unknown): ValidationResult<SlopRegistry> {
|
|
210
|
+
try {
|
|
211
|
+
const errors: SlopValidationError[] = [];
|
|
212
|
+
const root = record(value);
|
|
213
|
+
if (root === null) return { ok: false, errors: [{ field: '$', value, reason: 'must be an object' }] };
|
|
214
|
+
if (root.schema !== 'dz-slop-registry/1') {
|
|
215
|
+
errors.push({ field: 'schema', value: root.schema, reason: 'must equal dz-slop-registry/1' });
|
|
216
|
+
}
|
|
217
|
+
const metadata = record(root.metadata);
|
|
218
|
+
const reference = metadata === null ? null : record(metadata.englishReference);
|
|
219
|
+
if (metadata === null || typeof metadata.policyVersion !== 'string' || typeof metadata.owner !== 'string' ||
|
|
220
|
+
typeof metadata.reviewCadence !== 'string' || reference === null ||
|
|
221
|
+
reference.repository !== 'https://github.com/NousResearch/autonovel' ||
|
|
222
|
+
typeof reference.commit !== 'string' || !/^[0-9a-f]{40}$/.test(reference.commit) ||
|
|
223
|
+
reference.path !== 'ANTI-SLOP.md' || reference.license !== 'none-declared' ||
|
|
224
|
+
typeof reference.retrieved !== 'string' || typeof reference.use !== 'string') {
|
|
225
|
+
errors.push({ field: 'metadata', value: root.metadata, reason: 'must carry owner, cadence, and the pinned unlicensed reference record' });
|
|
226
|
+
}
|
|
227
|
+
const markers = root.markers;
|
|
228
|
+
const adjectives = root.adjectives;
|
|
229
|
+
if (!Array.isArray(markers) || markers.length === 0) {
|
|
230
|
+
errors.push({ field: 'markers', value: markers, reason: 'must contain entries' });
|
|
231
|
+
}
|
|
232
|
+
if (!Array.isArray(adjectives) || adjectives.length === 0) {
|
|
233
|
+
errors.push({ field: 'adjectives', value: adjectives, reason: 'must contain entries' });
|
|
234
|
+
}
|
|
235
|
+
const seen = new Set<string>();
|
|
236
|
+
if (Array.isArray(markers)) markers.forEach((entry, index) => validateRegistryEntry(entry, `markers[${index}]`, seen, errors));
|
|
237
|
+
if (Array.isArray(adjectives)) adjectives.forEach((entry, index) => validateRegistryEntry(entry, `adjectives[${index}]`, seen, errors));
|
|
238
|
+
if (Array.isArray(markers) && !markers.some((entry) => record(entry)?.language === 'en')) {
|
|
239
|
+
errors.push({ field: 'markers', value: markers, reason: 'must contain EN markers' });
|
|
240
|
+
}
|
|
241
|
+
if (Array.isArray(markers) && !markers.some((entry) => record(entry)?.language === 'ru')) {
|
|
242
|
+
errors.push({ field: 'markers', value: markers, reason: 'must contain RU markers' });
|
|
243
|
+
}
|
|
244
|
+
if (Array.isArray(adjectives) && !adjectives.some((entry) => record(entry)?.language === 'en')) {
|
|
245
|
+
errors.push({ field: 'adjectives', value: adjectives, reason: 'must contain EN adjectives' });
|
|
246
|
+
}
|
|
247
|
+
if (Array.isArray(adjectives) && !adjectives.some((entry) => record(entry)?.language === 'ru')) {
|
|
248
|
+
errors.push({ field: 'adjectives', value: adjectives, reason: 'must contain RU adjectives' });
|
|
249
|
+
}
|
|
250
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
251
|
+
return { ok: true, value: value as SlopRegistry };
|
|
252
|
+
} catch (error) {
|
|
253
|
+
return { ok: false, errors: [{ field: '$', value, reason: error instanceof Error ? error.message : 'registry validation failed' }] };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
interface SourceLine {
|
|
258
|
+
readonly raw: string;
|
|
259
|
+
readonly visible: string;
|
|
260
|
+
readonly line: number;
|
|
261
|
+
readonly offset: number;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
interface Unit {
|
|
265
|
+
readonly kind: 'prose' | 'list';
|
|
266
|
+
readonly lines: readonly SourceLine[];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
interface WordToken {
|
|
270
|
+
readonly normalized: string;
|
|
271
|
+
readonly text: string;
|
|
272
|
+
readonly line: number;
|
|
273
|
+
readonly columnStart: number;
|
|
274
|
+
readonly columnEnd: number;
|
|
275
|
+
readonly startOffset: number;
|
|
276
|
+
readonly endOffset: number;
|
|
277
|
+
readonly visibleLine: string;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function sourceLines(text: string): { raw: string; line: number; offset: number }[] {
|
|
281
|
+
const lines: { raw: string; line: number; offset: number }[] = [];
|
|
282
|
+
let offset = 0;
|
|
283
|
+
let line = 1;
|
|
284
|
+
while (offset <= text.length) {
|
|
285
|
+
let end = offset;
|
|
286
|
+
while (end < text.length && text[end] !== '\n' && text[end] !== '\r') end += 1;
|
|
287
|
+
lines.push({ raw: text.slice(offset, end), line, offset });
|
|
288
|
+
if (end >= text.length) break;
|
|
289
|
+
if (text[end] === '\r' && text[end + 1] === '\n') end += 1;
|
|
290
|
+
offset = end + 1;
|
|
291
|
+
line += 1;
|
|
292
|
+
}
|
|
293
|
+
return lines;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function maskRange(chars: string[], start: number, end: number): void {
|
|
297
|
+
for (let i = Math.max(0, start); i < Math.min(chars.length, end); i += 1) chars[i] = ' ';
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function visibleMarkdown(raw: string): string {
|
|
301
|
+
// RegExp match offsets are UTF-16 code-unit offsets, so keep the projection in
|
|
302
|
+
// code units too. Spreading would turn astral characters into one array slot
|
|
303
|
+
// and shift every later source location.
|
|
304
|
+
const chars = raw.split('');
|
|
305
|
+
const maskMatches = (regex: RegExp, keep = 0): void => {
|
|
306
|
+
regex.lastIndex = 0;
|
|
307
|
+
for (let match = regex.exec(raw); match !== null; match = regex.exec(raw)) {
|
|
308
|
+
maskRange(chars, match.index + keep, match.index + match[0].length);
|
|
309
|
+
if (match[0].length === 0) regex.lastIndex += 1;
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
maskMatches(/`+[^`]*`+/g);
|
|
313
|
+
maskMatches(/\]\([^)]*\)/g, 1);
|
|
314
|
+
maskMatches(/<[^>]*>/g);
|
|
315
|
+
const heading = /^\s{0,3}#{1,6}\s+/.exec(raw);
|
|
316
|
+
if (heading !== null) maskRange(chars, 0, heading[0].length);
|
|
317
|
+
const bullet = /^\s{0,3}(?:[-+*]|\d+[.)])\s+/.exec(raw);
|
|
318
|
+
if (bullet !== null) maskRange(chars, 0, bullet[0].length);
|
|
319
|
+
for (let i = 0; i < chars.length; i += 1) {
|
|
320
|
+
if ('*_~[]'.includes(chars[i] ?? '')) chars[i] = ' ';
|
|
321
|
+
}
|
|
322
|
+
return chars.join('');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function projectUnits(text: string): { units: Unit[]; diagnostics: SlopDiagnostic[] } {
|
|
326
|
+
const units: Unit[] = [];
|
|
327
|
+
const diagnostics: SlopDiagnostic[] = [];
|
|
328
|
+
let prose: SourceLine[] = [];
|
|
329
|
+
let list: SourceLine[] = [];
|
|
330
|
+
let fence: '`' | '~' | null = null;
|
|
331
|
+
let inExample = false;
|
|
332
|
+
let exampleStartLine = 1;
|
|
333
|
+
let frontmatter = false;
|
|
334
|
+
let frontmatterEligible = true;
|
|
335
|
+
const flushProse = (): void => {
|
|
336
|
+
if (prose.length > 0) units.push({ kind: 'prose', lines: prose });
|
|
337
|
+
prose = [];
|
|
338
|
+
};
|
|
339
|
+
const flushList = (): void => {
|
|
340
|
+
if (list.length > 0) units.push({ kind: 'list', lines: list });
|
|
341
|
+
list = [];
|
|
342
|
+
};
|
|
343
|
+
const boundary = (): void => { flushProse(); flushList(); };
|
|
344
|
+
|
|
345
|
+
for (const source of sourceLines(text)) {
|
|
346
|
+
const trimmed = source.raw.trim();
|
|
347
|
+
if (frontmatterEligible && source.line === 1 && trimmed === '---') {
|
|
348
|
+
frontmatter = true;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
frontmatterEligible = false;
|
|
352
|
+
if (frontmatter) {
|
|
353
|
+
if (trimmed === '---') frontmatter = false;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (trimmed === '<!-- slop-lint:example:start -->') {
|
|
357
|
+
boundary();
|
|
358
|
+
if (!inExample) exampleStartLine = source.line;
|
|
359
|
+
inExample = true;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (trimmed === '<!-- slop-lint:example:end -->') {
|
|
363
|
+
boundary();
|
|
364
|
+
inExample = false;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (inExample) continue;
|
|
368
|
+
const fenceMatch = /^\s*(`{3,}|~{3,})/.exec(source.raw);
|
|
369
|
+
if (fenceMatch !== null) {
|
|
370
|
+
boundary();
|
|
371
|
+
const marker = fenceMatch[1]![0] as '`' | '~';
|
|
372
|
+
if (fence === null) fence = marker;
|
|
373
|
+
else if (fence === marker) fence = null;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (fence !== null) continue;
|
|
377
|
+
if (trimmed === '' || /^\s*>/.test(source.raw)) {
|
|
378
|
+
boundary();
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
const projected: SourceLine = { ...source, visible: visibleMarkdown(source.raw) };
|
|
382
|
+
if (/^\s{0,3}(?:[-+*]|\d+[.)])\s+/.test(source.raw)) {
|
|
383
|
+
flushProse();
|
|
384
|
+
list.push(projected);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (/^\s{0,3}#{1,6}\s+/.test(source.raw) || /^\s*\|.*\|\s*$/.test(source.raw)) {
|
|
388
|
+
boundary();
|
|
389
|
+
units.push({ kind: 'prose', lines: [projected] });
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
// A nonblank continuation remains part of the contiguous list section. This makes the
|
|
393
|
+
// configured line ratio meaningful and prevents prose-heavy interrupted lists from being
|
|
394
|
+
// treated as six adjacent one-line bullets.
|
|
395
|
+
if (list.length > 0) {
|
|
396
|
+
list.push(projected);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
prose.push(projected);
|
|
400
|
+
}
|
|
401
|
+
boundary();
|
|
402
|
+
if (inExample) {
|
|
403
|
+
diagnostics.push({ code: 'unclosed-example-block', line: exampleStartLine, message: 'slop-lint example block is not closed' });
|
|
404
|
+
}
|
|
405
|
+
return { units: units.filter((unit) => tokensFor(unit).length > 0), diagnostics };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function tokensFor(unit: Unit): WordToken[] {
|
|
409
|
+
const tokens: WordToken[] = [];
|
|
410
|
+
const word = /[\p{L}\p{N}]+(?:[-'’][\p{L}\p{N}]+)*/gu;
|
|
411
|
+
for (const source of unit.lines) {
|
|
412
|
+
word.lastIndex = 0;
|
|
413
|
+
for (let match = word.exec(source.visible); match !== null; match = word.exec(source.visible)) {
|
|
414
|
+
const value = match[0];
|
|
415
|
+
tokens.push({
|
|
416
|
+
normalized: value.normalize('NFC').toLocaleLowerCase('und'),
|
|
417
|
+
text: source.raw.slice(match.index, match.index + value.length),
|
|
418
|
+
line: source.line,
|
|
419
|
+
columnStart: match.index + 1,
|
|
420
|
+
columnEnd: match.index + value.length + 1,
|
|
421
|
+
startOffset: source.offset + match.index,
|
|
422
|
+
endOffset: source.offset + match.index + value.length,
|
|
423
|
+
visibleLine: source.visible,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return tokens;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
interface CompiledEntry {
|
|
431
|
+
readonly id: string;
|
|
432
|
+
readonly language: SlopLanguage;
|
|
433
|
+
readonly kind: 'form' | 'stem' | 'phrase';
|
|
434
|
+
readonly values: readonly (readonly string[])[];
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function valueTokens(value: string): string[] {
|
|
438
|
+
return (value.normalize('NFC').toLocaleLowerCase('und').match(/[\p{L}\p{N}]+(?:[-'’][\p{L}\p{N}]+)*/gu) ?? []);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function compile(entries: readonly SlopRegistryEntry[]): CompiledEntry[] {
|
|
442
|
+
return entries.map((entry) => ({
|
|
443
|
+
id: entry.id,
|
|
444
|
+
language: entry.language,
|
|
445
|
+
kind: entry.match.kind,
|
|
446
|
+
values: entry.match.values.map(valueTokens).filter((value) => value.length > 0),
|
|
447
|
+
}));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function tokenMatches(token: string, expected: string, kind: CompiledEntry['kind']): boolean {
|
|
451
|
+
return kind === 'stem' ? token.startsWith(expected) : token === expected;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
interface MatchCandidate {
|
|
455
|
+
readonly entry: CompiledEntry;
|
|
456
|
+
readonly first: number;
|
|
457
|
+
readonly last: number;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function candidatesFor(tokens: readonly WordToken[], entries: readonly CompiledEntry[]): MatchCandidate[] {
|
|
461
|
+
const hasLatin = tokens.some((token) => /\p{Script=Latin}/u.test(token.normalized));
|
|
462
|
+
const hasCyrillic = tokens.some((token) => /\p{Script=Cyrillic}/u.test(token.normalized));
|
|
463
|
+
const candidates: MatchCandidate[] = [];
|
|
464
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
465
|
+
for (const entry of entries) {
|
|
466
|
+
if ((entry.language === 'en' && !hasLatin) || (entry.language === 'ru' && !hasCyrillic)) continue;
|
|
467
|
+
for (const value of entry.values) {
|
|
468
|
+
if (index + value.length > tokens.length) continue;
|
|
469
|
+
let matches = true;
|
|
470
|
+
for (let part = 0; part < value.length; part += 1) {
|
|
471
|
+
const actual = tokens[index + part]?.normalized ?? '';
|
|
472
|
+
const expected = value[part] ?? '';
|
|
473
|
+
if (!tokenMatches(actual, expected, entry.kind)) { matches = false; break; }
|
|
474
|
+
}
|
|
475
|
+
if (matches) candidates.push({ entry, first: index, last: index + value.length - 1 });
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return candidates;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function nonOverlapping(candidates: readonly MatchCandidate[]): MatchCandidate[] {
|
|
483
|
+
const selected: MatchCandidate[] = [];
|
|
484
|
+
const occupied = new Set<number>();
|
|
485
|
+
for (const candidate of [...candidates].sort((a, b) =>
|
|
486
|
+
(b.last - b.first) - (a.last - a.first) || a.entry.id.localeCompare(b.entry.id) || a.first - b.first)) {
|
|
487
|
+
let overlap = false;
|
|
488
|
+
for (let index = candidate.first; index <= candidate.last; index += 1) if (occupied.has(index)) overlap = true;
|
|
489
|
+
if (overlap) continue;
|
|
490
|
+
selected.push(candidate);
|
|
491
|
+
for (let index = candidate.first; index <= candidate.last; index += 1) occupied.add(index);
|
|
492
|
+
}
|
|
493
|
+
return selected.sort((a, b) => a.first - b.first || compareText(a.entry.id, b.entry.id));
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function compareText(a: string, b: string): number {
|
|
497
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function thresholds(config: SlopLintConfig, density = 0): SlopFindingThresholds {
|
|
501
|
+
return {
|
|
502
|
+
densityPer100Words: density,
|
|
503
|
+
minimumMarkers: config.lexicalMinimumMarkers,
|
|
504
|
+
wordFloor: config.lexicalWordFloor,
|
|
505
|
+
bulletMinimumItems: config.bulletMinimumItems,
|
|
506
|
+
bulletMinimumLineRatio: config.bulletMinimumLineRatio,
|
|
507
|
+
bulletMaximumMeanWords: config.bulletMaximumMeanWords,
|
|
508
|
+
adjectiveStackSize: config.adjectiveStackSize,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function metrics(overrides: Partial<SlopFindingMetrics> = {}): SlopFindingMetrics {
|
|
513
|
+
return {
|
|
514
|
+
markerCount: 0,
|
|
515
|
+
distinctMarkers: 0,
|
|
516
|
+
visibleWords: 0,
|
|
517
|
+
densityPer100Words: 0,
|
|
518
|
+
listItems: 0,
|
|
519
|
+
listLineRatio: 0,
|
|
520
|
+
listMeanWords: 0,
|
|
521
|
+
adjectiveCount: 0,
|
|
522
|
+
...overrides,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function clip(value: string, limit = 160): string {
|
|
527
|
+
const compact = value.replace(/\s+/gu, ' ').trim();
|
|
528
|
+
return compact.length > limit ? `${compact.slice(0, limit - 1)}…` : compact;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function evidenceFor(text: string, tokens: readonly WordToken[], match: MatchCandidate): SlopEvidence {
|
|
532
|
+
const first = tokens[match.first]!;
|
|
533
|
+
const last = tokens[match.last]!;
|
|
534
|
+
return {
|
|
535
|
+
id: match.entry.id,
|
|
536
|
+
language: match.entry.language,
|
|
537
|
+
normalizedSpan: tokens.slice(match.first, match.last + 1).map((token) => token.normalized).join(' '),
|
|
538
|
+
text: text.slice(first.startOffset, last.endOffset),
|
|
539
|
+
lineStart: first.line,
|
|
540
|
+
columnStart: first.columnStart,
|
|
541
|
+
lineEnd: last.line,
|
|
542
|
+
columnEnd: last.columnEnd,
|
|
543
|
+
startOffset: first.startOffset,
|
|
544
|
+
endOffset: last.endOffset,
|
|
545
|
+
count: 1,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function unitExcerpt(unit: Unit): string {
|
|
550
|
+
return clip(unit.lines.map((line) => line.raw).join('\n'));
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function lexicalFinding(
|
|
554
|
+
text: string,
|
|
555
|
+
unit: Unit,
|
|
556
|
+
paragraph: number,
|
|
557
|
+
tokens: readonly WordToken[],
|
|
558
|
+
entries: readonly CompiledEntry[],
|
|
559
|
+
config: SlopLintConfig,
|
|
560
|
+
): SlopFinding | null {
|
|
561
|
+
const selected = nonOverlapping(candidatesFor(tokens, entries));
|
|
562
|
+
const distinctMarkers = new Set(selected.map((match) => match.entry.id)).size;
|
|
563
|
+
if (distinctMarkers < config.lexicalMinimumMarkers) return null;
|
|
564
|
+
const languages = new Set(selected.map((match) => match.entry.language));
|
|
565
|
+
const language: SlopFinding['language'] = languages.size === 2 ? 'mixed' : selected[0]!.entry.language;
|
|
566
|
+
const densityThreshold = language === 'mixed'
|
|
567
|
+
? Math.max(config.lexicalDensityPer100Words.en, config.lexicalDensityPer100Words.ru)
|
|
568
|
+
: config.lexicalDensityPer100Words[language];
|
|
569
|
+
// The default policy intentionally has two regimes. The distinct-ID floor owns W <= 50;
|
|
570
|
+
// density is a dilution cap that can reject the same two-hit cluster from W = 51 onward.
|
|
571
|
+
const exactDensity = 100 * selected.length / Math.max(tokens.length, config.lexicalWordFloor);
|
|
572
|
+
if (exactDensity < densityThreshold) return null;
|
|
573
|
+
const evidence = selected.map((match) => evidenceFor(text, tokens, match));
|
|
574
|
+
const first = evidence[0]!;
|
|
575
|
+
const last = evidence[evidence.length - 1]!;
|
|
576
|
+
return {
|
|
577
|
+
ruleId: 'lexical-density',
|
|
578
|
+
severity: 'advisory',
|
|
579
|
+
paragraph,
|
|
580
|
+
lineStart: first.lineStart,
|
|
581
|
+
columnStart: first.columnStart,
|
|
582
|
+
lineEnd: last.lineEnd,
|
|
583
|
+
columnEnd: last.columnEnd,
|
|
584
|
+
language,
|
|
585
|
+
excerpt: unitExcerpt(unit),
|
|
586
|
+
evidence,
|
|
587
|
+
metrics: metrics({ markerCount: selected.length, distinctMarkers, visibleWords: tokens.length, densityPer100Words: Math.round(exactDensity * 100) / 100 }),
|
|
588
|
+
thresholds: thresholds(config, densityThreshold),
|
|
589
|
+
suggestion: 'Replace repeated stock wording with concrete facts, or keep the terms and document why the cluster is necessary.',
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function stockAdjectiveLeadCount(unit: Unit, entries: readonly CompiledEntry[]): number {
|
|
594
|
+
let count = 0;
|
|
595
|
+
for (const line of unit.lines) {
|
|
596
|
+
if (!/^\s{0,3}(?:[-+*]|\d+[.)])\s+/.test(line.raw)) continue;
|
|
597
|
+
const lineTokens = tokensFor({ kind: 'list', lines: [line] });
|
|
598
|
+
if (candidatesFor(lineTokens, entries).some((candidate) => candidate.first === 0)) count += 1;
|
|
599
|
+
}
|
|
600
|
+
return count;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function bulletFinding(
|
|
604
|
+
unit: Unit,
|
|
605
|
+
paragraph: number,
|
|
606
|
+
adjectiveEntries: readonly CompiledEntry[],
|
|
607
|
+
config: SlopLintConfig,
|
|
608
|
+
): SlopFinding | null {
|
|
609
|
+
if (unit.kind !== 'list') return null;
|
|
610
|
+
const itemCount = unit.lines.filter((line) => /^\s{0,3}(?:[-+*]|\d+[.)])\s+/.test(line.raw)).length;
|
|
611
|
+
const lineRatio = unit.lines.length === 0 ? 0 : itemCount / unit.lines.length;
|
|
612
|
+
const visibleWords = tokensFor(unit).length;
|
|
613
|
+
const meanWords = itemCount === 0 ? 0 : visibleWords / itemCount;
|
|
614
|
+
if (itemCount < config.bulletMinimumItems || lineRatio < config.bulletMinimumLineRatio ||
|
|
615
|
+
meanWords > config.bulletMaximumMeanWords) return null;
|
|
616
|
+
// Short reference tables, TOCs, procedures, and file inventories occupy the same numeric range
|
|
617
|
+
// as synthetic bullet walls. Require the wall to repeat a closed-registry stock-adjective lead;
|
|
618
|
+
// the configured item floor is also the minimum number of qualifying leads.
|
|
619
|
+
const adjectiveLeadCount = stockAdjectiveLeadCount(unit, adjectiveEntries);
|
|
620
|
+
if (adjectiveLeadCount < config.bulletMinimumItems) return null;
|
|
621
|
+
const first = unit.lines[0]!;
|
|
622
|
+
const last = unit.lines[unit.lines.length - 1]!;
|
|
623
|
+
return {
|
|
624
|
+
ruleId: 'bullet-wall',
|
|
625
|
+
severity: 'advisory',
|
|
626
|
+
paragraph,
|
|
627
|
+
lineStart: first.line,
|
|
628
|
+
columnStart: 1,
|
|
629
|
+
lineEnd: last.line,
|
|
630
|
+
columnEnd: last.raw.length + 1,
|
|
631
|
+
language: 'structural',
|
|
632
|
+
excerpt: unitExcerpt(unit),
|
|
633
|
+
evidence: [],
|
|
634
|
+
metrics: metrics({
|
|
635
|
+
visibleWords,
|
|
636
|
+
listItems: itemCount,
|
|
637
|
+
listLineRatio: Math.round(lineRatio * 100) / 100,
|
|
638
|
+
listMeanWords: Math.round(meanWords * 100) / 100,
|
|
639
|
+
adjectiveCount: adjectiveLeadCount,
|
|
640
|
+
}),
|
|
641
|
+
thresholds: thresholds(config),
|
|
642
|
+
suggestion: 'Replace repeated stock-adjective leads with concrete, non-redundant claims; keep a reference list when its structure is essential.',
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function gapAllowsStack(first: WordToken, second: WordToken): boolean {
|
|
647
|
+
if (first.line !== second.line) return false;
|
|
648
|
+
const gap = first.visibleLine.slice(first.columnEnd - 1, second.columnStart - 1)
|
|
649
|
+
.replace(/[\s,*_~[\]()]+/gu, ' ')
|
|
650
|
+
.trim()
|
|
651
|
+
.toLowerCase();
|
|
652
|
+
return gap === '';
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function adjectiveFinding(
|
|
656
|
+
text: string,
|
|
657
|
+
unit: Unit,
|
|
658
|
+
paragraph: number,
|
|
659
|
+
tokens: readonly WordToken[],
|
|
660
|
+
entries: readonly CompiledEntry[],
|
|
661
|
+
config: SlopLintConfig,
|
|
662
|
+
): SlopFinding | null {
|
|
663
|
+
const matches = tokens.map((_, index) => nonOverlapping(candidatesFor(tokens.slice(index, index + 1), entries))[0] ?? null);
|
|
664
|
+
for (let index = 0; index + config.adjectiveStackSize <= tokens.length; index += 1) {
|
|
665
|
+
if (matches[index] === null || matches[index + 1] === null) continue;
|
|
666
|
+
if (index > 0 && matches[index - 1] !== null) continue;
|
|
667
|
+
|
|
668
|
+
const conjunctionIndex = index + 2;
|
|
669
|
+
const hasConjunction = tokens[conjunctionIndex]?.normalized === 'and' || tokens[conjunctionIndex]?.normalized === 'и';
|
|
670
|
+
const thirdIndex = hasConjunction ? index + 3 : index + 2;
|
|
671
|
+
const third = matches[thirdIndex];
|
|
672
|
+
if (third === null || third === undefined) continue;
|
|
673
|
+
const next = matches[thirdIndex + 1];
|
|
674
|
+
if (tokens[thirdIndex + 1] === undefined) continue;
|
|
675
|
+
if (next !== null && next !== undefined) continue;
|
|
676
|
+
|
|
677
|
+
const indices = [index, index + 1, thirdIndex];
|
|
678
|
+
if (!gapAllowsStack(tokens[index]!, tokens[index + 1]!)) continue;
|
|
679
|
+
if (hasConjunction) {
|
|
680
|
+
if (!gapAllowsStack(tokens[index + 1]!, tokens[conjunctionIndex]!) ||
|
|
681
|
+
!gapAllowsStack(tokens[conjunctionIndex]!, tokens[thirdIndex]!)) continue;
|
|
682
|
+
} else if (!gapAllowsStack(tokens[index + 1]!, tokens[thirdIndex]!)) continue;
|
|
683
|
+
|
|
684
|
+
const typed = indices.map((tokenIndex) => matches[tokenIndex]!) as MatchCandidate[];
|
|
685
|
+
if (new Set(typed.map((match) => match.entry.id)).size !== config.adjectiveStackSize) continue;
|
|
686
|
+
const evidence = typed.map((match, part) => evidenceFor(text, tokens, { ...match, first: indices[part]!, last: indices[part]! }));
|
|
687
|
+
return {
|
|
688
|
+
ruleId: 'triple-adjective-stack',
|
|
689
|
+
severity: 'advisory',
|
|
690
|
+
paragraph,
|
|
691
|
+
lineStart: evidence[0]!.lineStart,
|
|
692
|
+
columnStart: evidence[0]!.columnStart,
|
|
693
|
+
lineEnd: evidence[evidence.length - 1]!.lineEnd,
|
|
694
|
+
columnEnd: evidence[evidence.length - 1]!.columnEnd,
|
|
695
|
+
language: new Set(evidence.map((item) => item.language)).size === 2 ? 'mixed' : evidence[0]!.language,
|
|
696
|
+
excerpt: unitExcerpt(unit),
|
|
697
|
+
evidence,
|
|
698
|
+
metrics: metrics({ visibleWords: tokens.length, adjectiveCount: config.adjectiveStackSize }),
|
|
699
|
+
thresholds: thresholds(config),
|
|
700
|
+
suggestion: 'Keep only qualifiers that add distinct, testable information.',
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Pure deterministic style analysis. The caller supplies already-loaded policy data; this function
|
|
708
|
+
* performs no file, network, database, clock, locale, or process I/O and never throws.
|
|
709
|
+
*/
|
|
710
|
+
export function slopLint(
|
|
711
|
+
text: string,
|
|
712
|
+
input: { readonly config: SlopLintConfig; readonly registry: SlopRegistry },
|
|
713
|
+
): SlopLintResult {
|
|
714
|
+
try {
|
|
715
|
+
if (typeof text !== 'string') {
|
|
716
|
+
return { paragraphCount: 0, findings: [], diagnostics: [{ code: 'analysis-error', line: 1, message: 'text must be a string' }] };
|
|
717
|
+
}
|
|
718
|
+
if (text.length > 2 * 1024 * 1024) {
|
|
719
|
+
return { paragraphCount: 0, findings: [], diagnostics: [{ code: 'input-limit-exceeded', line: 1, message: 'input exceeds the 2 MiB core limit' }] };
|
|
720
|
+
}
|
|
721
|
+
const configResult = validateSlopLintConfig(input?.config);
|
|
722
|
+
if (!configResult.ok) {
|
|
723
|
+
return { paragraphCount: 0, findings: [], diagnostics: [{ code: 'analysis-error', line: 1, message: configResult.errors.map((error) => `${error.field}: ${error.reason}`).join('; ') }] };
|
|
724
|
+
}
|
|
725
|
+
const registryResult = parseSlopRegistry(input?.registry);
|
|
726
|
+
if (!registryResult.ok) {
|
|
727
|
+
return { paragraphCount: 0, findings: [], diagnostics: [{ code: 'analysis-error', line: 1, message: registryResult.errors.map((error) => `${error.field}: ${error.reason}`).join('; ') }] };
|
|
728
|
+
}
|
|
729
|
+
const projected = projectUnits(text);
|
|
730
|
+
const markerEntries = compile(registryResult.value.markers);
|
|
731
|
+
const adjectiveEntries = compile(registryResult.value.adjectives);
|
|
732
|
+
const findings: SlopFinding[] = [];
|
|
733
|
+
for (let index = 0; index < projected.units.length; index += 1) {
|
|
734
|
+
const unit = projected.units[index]!;
|
|
735
|
+
const tokens = tokensFor(unit);
|
|
736
|
+
const paragraph = index + 1;
|
|
737
|
+
const lexical = lexicalFinding(text, unit, paragraph, tokens, markerEntries, configResult.value);
|
|
738
|
+
const bullet = bulletFinding(unit, paragraph, adjectiveEntries, configResult.value);
|
|
739
|
+
const adjectives = adjectiveFinding(text, unit, paragraph, tokens, adjectiveEntries, configResult.value);
|
|
740
|
+
if (lexical !== null) findings.push(lexical);
|
|
741
|
+
if (bullet !== null) findings.push(bullet);
|
|
742
|
+
if (adjectives !== null) findings.push(adjectives);
|
|
743
|
+
if (findings.length > 1_000) {
|
|
744
|
+
return {
|
|
745
|
+
paragraphCount: projected.units.length,
|
|
746
|
+
findings: [],
|
|
747
|
+
diagnostics: [...projected.diagnostics, { code: 'input-limit-exceeded', line: unit.lines[0]?.line ?? 1, message: 'finding cap exceeded; no partial clean result was returned' }],
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
findings.sort((a, b) =>
|
|
752
|
+
a.lineStart - b.lineStart || a.columnStart - b.columnStart || compareText(a.ruleId, b.ruleId) ||
|
|
753
|
+
compareText(a.evidence[0]?.id ?? '', b.evidence[0]?.id ?? ''));
|
|
754
|
+
return { paragraphCount: projected.units.length, findings, diagnostics: projected.diagnostics };
|
|
755
|
+
} catch (error) {
|
|
756
|
+
return {
|
|
757
|
+
paragraphCount: 0,
|
|
758
|
+
findings: [],
|
|
759
|
+
diagnostics: [{ code: 'analysis-error', line: 1, message: error instanceof Error ? error.message : 'analysis failed' }],
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
}
|