rvlib-shipkit 0.35.8 → 0.37.0

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.
@@ -0,0 +1,436 @@
1
+ // STE prose checker — the mechanical subset of ASD-STE100 (Issue 9) that a
2
+ // machine can judge without reading for meaning. The standard has 53 rules
3
+ // and a ~900-word dictionary; this file implements the classes where a regex
4
+ // is not a guess, and stays silent everywhere else. The house standard and
5
+ // the exemptions: identity/writing-voice.md.
6
+ //
7
+ // Three tiers, because the cost of being wrong is not the same for each:
8
+ // fix — one mechanical rewrite, meaning preserved. `--fix` applies it.
9
+ // hard — a real violation whose repair is a judgement call. Reported.
10
+ // soft — needs a human read (length, voice). Warns, never blocks alone.
11
+ //
12
+ // The class ids are STABLE: a baseline count and a `steAllow` entry both
13
+ // name them.
14
+
15
+ export type ProseTier = 'fix' | 'hard' | 'soft'
16
+
17
+ export type ProseClass =
18
+ | 'contraction'
19
+ | 'abbrev'
20
+ | 'slop'
21
+ | 'modal'
22
+ | 'semicolon'
23
+ | 'comma-ing'
24
+ | 'perfect'
25
+ | 'vague'
26
+ | 'sentence'
27
+ | 'passive'
28
+ | 'paragraph'
29
+
30
+ export type ProseHit = {
31
+ line: number // 1-based, true to the file
32
+ start: number // offset into the source
33
+ end: number
34
+ cls: ProseClass
35
+ tier: ProseTier
36
+ text: string // the offending span
37
+ fix?: string // the replacement, when the class is mechanical
38
+ why: string // one line, what STE wants instead
39
+ }
40
+
41
+ export type ProseOpts = {
42
+ /** class ids this file or repo does not want checked */
43
+ allow?: ProseClass[]
44
+ /** procedural limit (20) instead of the descriptive one (25) */
45
+ strict?: boolean
46
+ }
47
+
48
+ const TIER: Record<ProseClass, ProseTier> = {
49
+ contraction: 'fix',
50
+ abbrev: 'fix',
51
+ slop: 'fix',
52
+ modal: 'soft',
53
+ semicolon: 'hard',
54
+ 'comma-ing': 'soft',
55
+ perfect: 'soft',
56
+ vague: 'hard',
57
+ sentence: 'soft',
58
+ passive: 'soft',
59
+ paragraph: 'soft',
60
+ }
61
+
62
+ export const PROSE_CLASSES = Object.keys(TIER) as ProseClass[]
63
+ export const tierOf = (c: ProseClass): ProseTier => TIER[c]
64
+
65
+ // --- vocabulary tables ---------------------------------------------------
66
+
67
+ /** contractions → the full form. STE keeps every word a reader can look up. */
68
+ const CONTRACTIONS: Record<string, string> = {
69
+ "aren't": 'are not', "can't": 'cannot', "couldn't": 'could not', "didn't": 'did not',
70
+ "doesn't": 'does not', "don't": 'do not', "hadn't": 'had not', "hasn't": 'has not',
71
+ "haven't": 'have not', "isn't": 'is not', "mustn't": 'must not', "shouldn't": 'should not',
72
+ "wasn't": 'was not', "weren't": 'were not', "won't": 'will not', "wouldn't": 'would not',
73
+ "he's": 'he is', "here's": 'here is', "it's": 'it is', "let's": 'let us', "she's": 'she is',
74
+ "that's": 'that is', "there's": 'there is', "they're": 'they are', "they've": 'they have',
75
+ "we're": 'we are', "we've": 'we have', "what's": 'what is', "who's": 'who is',
76
+ "you're": 'you are', "you've": 'you have', "i'm": 'I am', "we'll": 'we will',
77
+ "you'll": 'you will', "it'll": 'it will', "they'll": 'they will',
78
+ }
79
+
80
+ /** latin and shorthand a non-native reader must decode */
81
+ const ABBREV: Record<string, string> = {
82
+ 'e.g.': 'for example', 'i.e.': 'that is', 'vs.': 'against', 'w/': 'with',
83
+ }
84
+
85
+ /** one mechanical replacement, meaning preserved. Sourced from the STE
86
+ * dictionary's not-approved column where the alternative is unambiguous,
87
+ * plus the AI-slop words rule 1.5 (keep it short) covers.
88
+ * `verb: true` lets the checker carry the inflection across, so `utilizes`
89
+ * becomes `uses` and not `utilizes`. Without it a table of base forms
90
+ * silently misses most real prose. */
91
+ const SUBSTITUTIONS: { from: string; to: string; verb?: boolean }[] = [
92
+ { from: 'leverage', to: 'use', verb: true },
93
+ { from: 'utilize', to: 'use', verb: true },
94
+ { from: 'commence', to: 'start', verb: true },
95
+ { from: 'terminate', to: 'stop', verb: true },
96
+ { from: 'obtain', to: 'get', verb: true },
97
+ { from: 'facilitate', to: 'help', verb: true },
98
+ { from: 'streamline', to: 'make simpler', verb: false },
99
+ { from: 'prior to', to: 'before' },
100
+ { from: 'in order to', to: 'to' },
101
+ { from: 'due to the fact that', to: 'because' },
102
+ { from: 'in the event that', to: 'if' },
103
+ { from: 'out of the box', to: 'by default' },
104
+ { from: 'under the hood', to: 'internally' },
105
+ { from: 'a number of', to: 'some' },
106
+ { from: 'is able to', to: 'can' },
107
+ { from: 'are able to', to: 'can' },
108
+ { from: 'has the ability to', to: 'can' },
109
+ { from: 'plethora', to: 'many' },
110
+ { from: 'myriad', to: 'many' },
111
+ { from: 'additionally', to: 'also' },
112
+ { from: 'furthermore', to: 'also' },
113
+ { from: 'moreover', to: 'also' },
114
+ ]
115
+
116
+ /** carry an English verb inflection from the banned word onto its
117
+ * replacement. Only the four regular endings, because an irregular form is
118
+ * a guess and this table exists to avoid guesses. */
119
+ const IRREGULAR: Record<string, Record<string, string>> = {
120
+ get: { s: 'gets', es: 'gets', d: 'got', ed: 'got', ing: 'getting' },
121
+ stop: { s: 'stops', es: 'stops', d: 'stopped', ed: 'stopped', ing: 'stopping' },
122
+ run: { s: 'runs', es: 'runs', d: 'ran', ed: 'ran', ing: 'running' },
123
+ }
124
+
125
+ function inflect(base: string, suffix: string): string {
126
+ if (suffix === '') return base
127
+ const irr = IRREGULAR[base]?.[suffix]
128
+ if (irr != null) return irr
129
+ if (suffix === 's' || suffix === 'es') return /(s|x|z|ch|sh)$/.test(base) ? `${base}es` : `${base}s`
130
+ if (suffix === 'd' || suffix === 'ed') return base.endsWith('e') ? `${base}d` : `${base}ed`
131
+ if (suffix === 'ing') return base.endsWith('e') ? `${base.slice(0, -1)}ing` : `${base}ing`
132
+ return base
133
+ }
134
+
135
+ /** the same idea where the replacement must eat neighbouring punctuation.
136
+ * 'However, X' becomes 'But X', never 'But, X'. */
137
+ const PHRASE_SUBSTITUTIONS: { re: RegExp; to: string; why: string }[] = [
138
+ { re: /\bhowever,\s+/gi, to: 'but ', why: "STE writes 'but'" },
139
+ { re: /\bnevertheless,\s+/gi, to: 'but ', why: "STE writes 'but'" },
140
+ { re: /\bnonetheless,\s+/gi, to: 'but ', why: "STE writes 'but'" },
141
+ ]
142
+
143
+ /** deletable: the word carries no fact, and removing it leaves valid grammar.
144
+ * Intensifiers are deliberately NOT here. `very` is an approved STE word, and
145
+ * an intensifier a writer chose states a degree, so deleting it edits the
146
+ * claim rather than the prose. */
147
+ const DELETIONS = [
148
+ 'simply', 'seamlessly', 'effortlessly', 'blazingly', 'basically', 'essentially',
149
+ 'it is worth noting that', 'it is important to note that', 'needless to say',
150
+ ]
151
+
152
+ /** a real violation with no safe mechanical repair: the writer picks the fact */
153
+ const VAGUE: Record<string, string> = {
154
+ robust: 'delete it, or give the measurable property',
155
+ powerful: 'delete it, or give the measurable property',
156
+ comprehensive: 'delete it, or name what it covers',
157
+ performant: 'delete it, or give the number',
158
+ 'state-of-the-art': 'delete it',
159
+ 'best-in-class': 'delete it',
160
+ ensure: 'write "make sure that", or name the guarantee',
161
+ etc: 'name the items, or write "and the rest of <the family>"',
162
+ 'and/or': 'pick one, or write "X, or Y, or both"',
163
+ 'as needed': 'state the condition',
164
+ 'as necessary': 'state the condition',
165
+ 'gracefully handles': 'say what it does',
166
+ }
167
+
168
+ const MODALS = ['should', 'would', 'may', 'might', 'could']
169
+
170
+ const PARTICIPLES =
171
+ 'written|built|done|made|kept|held|read|sent|shown|given|taken|known|seen|found|lost|run|set|put|told|left|drawn|thrown|caught|brought|bought|spent|meant|dealt|felt|gone|come|become|begun'
172
+
173
+ // --- masking -------------------------------------------------------------
174
+
175
+ const blankOut = (m: string): string => m.replace(/[^\n]/g, ' ')
176
+
177
+ /** everything that is NOT house prose, blanked to spaces of the same length,
178
+ * so every offset a regex reports still points at the real source.
179
+ * Out of scope by the standard's own Untouchables plus the house exemptions:
180
+ * frontmatter, code fences, inline code, double-quoted verbatim spans,
181
+ * URLs and link targets, and `>` blockquotes (paste-ready copy). */
182
+ export function maskProse(src: string): string {
183
+ let m = src.replace(/^---\n[\s\S]*?\n---\n/, blankOut)
184
+ m = m.replace(/```[\s\S]*?```/g, blankOut)
185
+ m = m.replace(/`[^`\n]*`/g, blankOut)
186
+ // a verbatim quote is spec, never house prose. The cap keeps one unpaired
187
+ // quote from swallowing the rest of the page.
188
+ m = m.replace(/"[^"]{0,800}"/g, blankOut)
189
+ m = m.replace(/“[^”]{0,800}”/g, blankOut)
190
+ // a verbatim quoted with SINGLE quotes, which house pages use as often as
191
+ // double ones. The opening quote must follow a boundary, so an apostrophe
192
+ // inside a word (`don't`) can never open a span and swallow the line.
193
+ // a word-internal apostrophe (`you're`) stays INSIDE the span. Real
194
+ // quotations are full of them, and a span that stops at the first one
195
+ // masks nothing, after which the fixer rewrites the quotation.
196
+ m = m.replace(/(^|[\s(\[])'(?:[^'\n]|(?<=\w)'(?=\w))*'(?=[\s.,;:!?)\]]|$)/gm, (full) => full[0] + blankOut(full.slice(1)))
197
+ m = m.replace(/(^|[\s(\[])‘[^’\n]{0,300}’/gm, (full) => full[0] + blankOut(full.slice(1)))
198
+ m = m.replace(/\]\([^)\n]*\)/g, blankOut)
199
+ m = m.replace(/https?:\/\/\S+/g, blankOut)
200
+ m = m.replace(/^[ \t]*>.*$/gm, blankOut)
201
+ return m
202
+ }
203
+
204
+ /** a table row is structure: word classes still apply inside a cell, sentence
205
+ * and paragraph limits do not. Same for a heading. */
206
+ /** `is unmaintained` is a state, not a passive voice: there is no verb `to
207
+ * unmaintain`, so no actor exists to name. The `un-` prefix catches almost
208
+ * the whole family; the rest are participles coined off a noun. */
209
+ const COINED = new Set(['gitignored', 'backticked', 'sandboxed', 'siloed'])
210
+ /** words that end in the two letters and were never a verb. `be + \w+ed` cannot
211
+ * tell them from a participle, and it reported `is red` as a passive voice. */
212
+ const NOT_PARTICIPLES = new Set(['red', 'tired', 'hundred', 'aged', 'learned', 'sacred', 'bed', 'need', 'feed', 'speed', 'seed', 'freed', 'agreed', 'indeed'])
213
+ const isAdjectival = (participle: string): boolean => {
214
+ const w = participle.toLowerCase()
215
+ return (w.startsWith('un') && w.endsWith('ed')) || COINED.has(w) || NOT_PARTICIPLES.has(w)
216
+ }
217
+
218
+ const isStructureLine = (l: string): boolean => /^\s*\|/.test(l) || /^\s*#{1,6}\s/.test(l) || /^\s*[-|: ]+$/.test(l)
219
+
220
+ // --- sentence segmentation ----------------------------------------------
221
+
222
+ /** house prose puts one bullet per line and uses ` — ` as a SEPARATOR, never
223
+ * as sentence punctuation (identity/writing-voice.md). Both are boundaries
224
+ * here, so a route line is not read as one 40-word sentence. */
225
+ const splitSentences = (line: string): { text: string; at: number }[] => {
226
+ const out: { text: string; at: number }[] = []
227
+ let at = 0
228
+ for (const part of line.split(/(?<=[.!?])\s+|\s+[—–]\s+/)) {
229
+ const i = line.indexOf(part, at)
230
+ out.push({ text: part, at: i < 0 ? at : i })
231
+ at = (i < 0 ? at : i) + part.length
232
+ }
233
+ return out
234
+ }
235
+
236
+ /** STE counts a code span, a command or a path as ONE word */
237
+ const wordCount = (s: string): number =>
238
+ s
239
+ .replace(/`[^`]*`/g, 'x')
240
+ .replace(/^\s*(?:[-*+]|\d+\.)\s+/, '')
241
+ .replace(/[*_]/g, '')
242
+ .split(/\s+/)
243
+ .filter((w) => /[A-Za-z0-9]/.test(w)).length
244
+
245
+ // --- the checker ---------------------------------------------------------
246
+
247
+ const capitalizeLike = (sample: string, replacement: string): string =>
248
+ /^[A-Z]/.test(sample) ? replacement.charAt(0).toUpperCase() + replacement.slice(1) : replacement
249
+
250
+ const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
251
+
252
+ export function checkProse(src: string, opts: ProseOpts = {}): ProseHit[] {
253
+ const allow = new Set(opts.allow ?? [])
254
+ const masked = maskProse(src)
255
+ const hits: ProseHit[] = []
256
+ // line index → offset, once
257
+ const lineStart: number[] = [0]
258
+ for (let i = 0; i < src.length; i++) if (src[i] === '\n') lineStart.push(i + 1)
259
+ const lineOf = (off: number): number => {
260
+ let lo = 0
261
+ let hi = lineStart.length - 1
262
+ while (lo < hi) {
263
+ const mid = (lo + hi + 1) >> 1
264
+ if (lineStart[mid]! <= off) lo = mid
265
+ else hi = mid - 1
266
+ }
267
+ return lo + 1
268
+ }
269
+
270
+ const push = (cls: ProseClass, start: number, end: number, why: string, fix?: string) => {
271
+ if (allow.has(cls)) return
272
+ const text = src.slice(start, end)
273
+ hits.push({ line: lineOf(start), start, end, cls, tier: TIER[cls], text, fix, why })
274
+ }
275
+
276
+ const scan = (re: RegExp, run: (m: RegExpExecArray) => void) => {
277
+ re.lastIndex = 0
278
+ let m: RegExpExecArray | null
279
+ while ((m = re.exec(masked)) != null) {
280
+ if (m[0].trim() === '') continue
281
+ run(m)
282
+ }
283
+ }
284
+
285
+ // --- fix tier
286
+ scan(new RegExp(`\\b(${Object.keys(CONTRACTIONS).map(escapeRe).join('|')})`, 'gi'), (m) => {
287
+ const full = CONTRACTIONS[m[0].toLowerCase()]
288
+ if (full != null) push('contraction', m.index, m.index + m[0].length, 'STE keeps the full form', capitalizeLike(m[0], full))
289
+ })
290
+ scan(new RegExp(`(?<![\\w.])(${Object.keys(ABBREV).map(escapeRe).join('|')})`, 'gi'), (m) => {
291
+ const full = ABBREV[m[0].toLowerCase()]
292
+ if (full != null) push('abbrev', m.index, m.index + m[0].length, 'write the words a non-native reader can read', capitalizeLike(m[0], full))
293
+ })
294
+ // a verb entry matches its inflected forms too, and the ending rides onto
295
+ // the replacement
296
+ const verbs = SUBSTITUTIONS.filter((x) => x.verb === true)
297
+ const plain = SUBSTITUTIONS.filter((x) => x.verb !== true)
298
+ for (const e of verbs) {
299
+ // a base ending in `e` drops it before `-ing` (leverage → leveraging),
300
+ // so the stem is matched both ways
301
+ const stem = e.from.endsWith('e') ? e.from.slice(0, -1) : e.from
302
+ scan(new RegExp(`\\b(?:${escapeRe(e.from)}(es|s|ed|d)?|${escapeRe(stem)}(ing))\\b`, 'gi'), (m) => {
303
+ const to = inflect(e.to, (m[1] ?? m[2] ?? '').toLowerCase())
304
+ push('slop', m.index, m.index + m[0].length, `STE writes '${e.to}'`, capitalizeLike(m[0], to))
305
+ })
306
+ }
307
+ scan(new RegExp(`\\b(${plain.map((x) => escapeRe(x.from)).join('|')})\\b`, 'gi'), (m) => {
308
+ const e = plain.find((x) => x.from === m[0]!.toLowerCase())
309
+ if (e != null) push('slop', m.index, m.index + m[0].length, `STE writes '${e.to}'`, capitalizeLike(m[0], e.to))
310
+ })
311
+ // a deletion gives back exactly one space, and WHICH one depends on what
312
+ // follows: before punctuation the space in front goes with the word, or
313
+ // the page keeps `the world : publish`. At the start of a line the fix is
314
+ // withheld entirely, because deleting the first word moves the capital
315
+ // onto the next one and that is the writer's call.
316
+ scan(new RegExp(`( *)\\b(${DELETIONS.map(escapeRe).join('|')})\\b( *)`, 'gi'), (m) => {
317
+ const lead = m[1] ?? ''
318
+ let end = m.index + m[0].length
319
+ const after = masked[end] ?? ''
320
+ const before = masked[m.index - 1] ?? ''
321
+ // inside an enumeration the word owns one of the two commas around it,
322
+ // or deleting it leaves `Also,, X`
323
+ if (before === ',' && after === ',') end += 1
324
+ const punctuationFollows = /[.,:;!?)\]]/.test(after)
325
+ const fix = lead === '' ? undefined : punctuationFollows ? '' : lead
326
+ push('slop', m.index, end, 'the word carries no fact', fix)
327
+ })
328
+ for (const p of PHRASE_SUBSTITUTIONS)
329
+ scan(new RegExp(p.re.source, 'gi'), (m) => push('slop', m.index, m.index + m[0].length, p.why, capitalizeLike(m[0], p.to)))
330
+
331
+ // --- hard tier
332
+ scan(new RegExp(`\\b(${MODALS.join('|')})\\b`, 'gi'), (m) =>
333
+ push('modal', m.index, m.index + m[0].length, "a requirement is 'must', a possibility is 'can', a recommendation is a fact or it is deleted"),
334
+ )
335
+ scan(/;/g, (m) => push('semicolon', m.index, m.index + 1, 'write two sentences'))
336
+ scan(/,\s+(\w+ing)\b/g, (m) => {
337
+ if (/^(nothing|something|anything|everything|during|string|thing)$/i.test(m[1]!)) return
338
+ // an `-ing` word used as an ADJECTIVE (`, missing path`) is not the
339
+ // participial clause the rule bans, and no regex can tell them apart.
340
+ // Measured on a real wiki, roughly two in three hits were a noun
341
+ // in an enumeration (`selection, loading, empty, error and the pager`)
342
+ // rather than a clause, which is why this class is SOFT: a rule that
343
+ // cries wolf gets switched off, and then it guards nothing.
344
+ if (/^(missing|following|existing|remaining|leading|trailing|incoming|outgoing|ongoing|pending|willing|living|growing|surrounding|underlying)$/i.test(m[1]!)) return
345
+ // `Renaming a file, moving a responsibility, or changing any contract`
346
+ // is a coordinated list of gerund NOUNS, not the participial clause
347
+ // the rule bans. The tell is a second gerund behind `and` or `or`.
348
+ const line = masked.slice(masked.lastIndexOf('\n', m.index) + 1, (masked.indexOf('\n', m.index) + 1 || masked.length + 1) - 1)
349
+ if (/\b(and|or)\s+\w+ing\b/.test(line)) return
350
+ push('comma-ing', m.index, m.index + m[0].length, "STE bans an '-ing' verb after a comma: start a new sentence, or write the relative clause")
351
+ })
352
+ scan(new RegExp(`\\b(has|have|had)\\s+(been|\\w+ed|${PARTICIPLES})\\b`, 'gi'), (m) =>
353
+ push('perfect', m.index, m.index + m[0].length, 'STE uses simple tenses only'),
354
+ )
355
+ scan(new RegExp(`\\b(${Object.keys(VAGUE).map(escapeRe).join('|')})\\b`, 'gi'), (m) => {
356
+ const why = VAGUE[m[0].toLowerCase()]
357
+ if (why != null) push('vague', m.index, m.index + m[0].length, why)
358
+ })
359
+
360
+ // --- soft tier, line by line
361
+ const limit = opts.strict === true ? 20 : 25
362
+ const lines = masked.split('\n')
363
+ let paraStart = -1
364
+ let paraSentences = 0
365
+ const flushParagraph = (endLine: number) => {
366
+ if (paraStart >= 0 && paraSentences > 6)
367
+ push('paragraph', lineStart[paraStart]!, lineStart[paraStart]! + (lines[paraStart]?.length ?? 0), `${paraSentences} sentences in one paragraph: STE allows six`)
368
+ paraStart = -1
369
+ paraSentences = 0
370
+ void endLine
371
+ }
372
+ for (let i = 0; i < lines.length; i++) {
373
+ const line = lines[i]!
374
+ const off = lineStart[i]!
375
+ if (line.trim() === '') {
376
+ flushParagraph(i)
377
+ continue
378
+ }
379
+ if (isStructureLine(line)) {
380
+ flushParagraph(i)
381
+ continue
382
+ }
383
+ // passive voice: a form of `be` plus a past participle
384
+ const passRe = new RegExp(`\\b(is|are|was|were|be|been|being)\\s+(\\w+ed|${PARTICIPLES})\\b`, 'gi')
385
+ let pm: RegExpExecArray | null
386
+ while ((pm = passRe.exec(line)) != null) {
387
+ if (isAdjectival(pm[2]!)) continue
388
+ push('passive', off + pm.index, off + pm.index + pm[0].length, 'STE wants the active voice: name the actor')
389
+ }
390
+ const isBullet = /^\s*(?:[-*+]|\d+\.)\s/.test(line)
391
+ if (!isBullet) {
392
+ if (paraStart < 0) paraStart = i
393
+ }
394
+ for (const s of splitSentences(line)) {
395
+ const w = wordCount(s.text)
396
+ if (!isBullet && paraStart >= 0 && w > 0) paraSentences++
397
+ if (w > limit)
398
+ push('sentence', off + s.at, off + s.at + s.text.length, `${w} words: the limit is ${limit}`)
399
+ }
400
+ }
401
+ flushParagraph(lines.length)
402
+
403
+ return hits.sort((a, b) => a.start - b.start)
404
+ }
405
+
406
+ /** apply every mechanical rewrite, left to right, first hit wins.
407
+ *
408
+ * Two scans can claim the same characters: the deletion scan takes the space
409
+ * in front of its word, and a phrase substitution takes the space behind the
410
+ * comma it eats. Writing both over each other ate the text between them
411
+ * (`However, simply.` came back as `But `). So a hit that starts inside an
412
+ * already-applied span is DROPPED and the next pass picks it up on clean
413
+ * text, which is why this runs to a FIXED POINT rather than once. Bounded,
414
+ * because a fixer that cannot converge must say so rather than spin. */
415
+ export function fixProse(src: string, opts: ProseOpts = {}): { text: string; applied: number } {
416
+ let text = src
417
+ let applied = 0
418
+ for (let pass = 0; pass < 5; pass++) {
419
+ const hits = checkProse(text, opts).filter((h) => h.fix != null)
420
+ if (hits.length === 0) break
421
+ let out = ''
422
+ let cursor = 0
423
+ let wrote = 0
424
+ for (const h of [...hits].sort((a, b) => a.start - b.start)) {
425
+ if (h.start < cursor) continue
426
+ out += text.slice(cursor, h.start) + h.fix
427
+ cursor = h.end
428
+ wrote++
429
+ }
430
+ if (wrote === 0) break
431
+ text = out + text.slice(cursor)
432
+ applied += wrote
433
+ }
434
+ return { text, applied }
435
+ }
436
+
@@ -17,9 +17,12 @@ import { FLOOR_IDS, resolveFloor, type FloorResolution } from './core/floor.ts'
17
17
  export { resolveFloor, type FloorResolution, type FloorSource } from './core/floor.ts'
18
18
  import { evaluateContractsOnce } from './contracts/contracts-once.ts'
19
19
  import type { ContractManifest } from './contracts/contract-types.ts'
20
+ import type { ProseConfig } from './prose/run-prose.ts'
21
+ import { validateSmokeConfig, type SmokeConfig } from './smoke/run-smoke.ts'
20
22
  import { contractsFulfillness } from './rules/SK002-contracts-fulfillness.ts'
21
23
  import { BUILTIN_RULE_IDS, validateRules } from './rules/rule-registry.ts'
22
24
  import type { Rule } from './rules/rule.ts'
25
+ import { leakAllowProblems } from './hooks/leak-guard.ts'
23
26
  import type { HooksConfig } from './hooks/run-hooks.ts'
24
27
  import type { ReleaseConfig } from './release/run-release.ts'
25
28
  import { UNIT_DECL_KEYS, type ReleaseUnitDecl } from './release/units.ts'
@@ -66,6 +69,12 @@ export type RepoConfig = {
66
69
  creds?: { name: string; service?: string; mintUrl?: string; steps?: string[] }[]
67
70
  /** store-command template for a missing secret tile, `{name}` substituted (e.g. your secret manager's set command) */
68
71
  credsCommand?: string
72
+ /** READ-command template, the twin of `credsCommand`: prints one field of
73
+ * a secret, `{name}` and `{field}` (get = the value, account = the
74
+ * identity stored beside it) substituted, run WITHOUT a shell. The one
75
+ * way a value reaches a command (`shipkit smoke` login), so it never sits
76
+ * in config, argv or env. Required when `smoke.login` is declared. */
77
+ credsReadCommand?: string
69
78
  /** notify command for new RED needs while the hub serves, `{message}` substituted (e.g. a push-notification CLI) */
70
79
  notifyCommand?: string
71
80
  contracts?: { exempt?: Record<string, string>; extra?: ContractManifest[] }
@@ -129,6 +138,16 @@ export type RepoConfig = {
129
138
  * once here; a file that differs overrides with its own
130
139
  * `export const blocking = true`. With neither, the check fails loud. */
131
140
  checkDefaults?: { blocking?: boolean }
141
+ /** house prose in STE (identity/writing-voice.md), gated by rule SK014.
142
+ * `include` is what makes the rule run at all: a repo that declares no
143
+ * scope is skipped rather than failed, because STE binds the prose an
144
+ * agent WRITES and no repo can be opted in from the outside.
145
+ * prose/run-prose.ts */
146
+ prose?: ProseConfig
147
+ /** `shipkit smoke`: the deployed url, the routes a read only browser pass
148
+ * visits after a deploy, and how it logs in. The password is a secret
149
+ * NAME: the value never sits in config, argv or env. smoke/run-smoke.ts */
150
+ smoke?: SmokeConfig
132
151
  /** extra dashboard panels appended after the built-in Tasks/Checks (`shipkit tui`) */
133
152
  tui?: { panels?: TuiPanelDef[] }
134
153
  /** the `shipkit serve` hub: port + optional graph hooks */
@@ -136,7 +155,7 @@ export type RepoConfig = {
136
155
  logDir?: string
137
156
  }
138
157
 
139
- const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'visibility', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'logDir'])
158
+ const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'visibility', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'logDir', 'prose', 'smoke'])
140
159
 
141
160
  export function defineRepo(c: RepoConfig): RepoConfig {
142
161
  if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
@@ -182,6 +201,22 @@ export function defineRepo(c: RepoConfig): RepoConfig {
182
201
  // repo rules are validated against the built-ins, so a bad id, a duplicate,
183
202
  // or an SK squatter dies at config load, not silently mid-check
184
203
  if (c.rules != null) validateRules(c.rules, { label: 'defineRepo: rules', taken: BUILTIN_RULE_IDS })
204
+ // a smoke typo would land as a run that visits nothing and exits 0
205
+ if (c.smoke != null) validateSmokeConfig(c.smoke)
206
+ // a leak-guard waiver is the one config row that can make a gate quieter,
207
+ // so a malformed one dies here rather than being dropped at commit time
208
+ if (c.hooks?.leakAllow != null) {
209
+ const problems = leakAllowProblems(c.hooks.leakAllow, 'defineRepo: hooks.leakAllow', true)
210
+ if (problems.length > 0) throw new Error(problems[0])
211
+ }
212
+ if (c.credsReadCommand != null && !/\{name\}/.test(c.credsReadCommand))
213
+ throw new Error(`defineRepo: credsReadCommand must carry {name} (and {field} for get | account), e.g. 'my-secrets {field} {name}'`)
214
+ if (c.smoke?.login != null && (c.credsReadCommand ?? '') === '')
215
+ throw new Error(`defineRepo: smoke.login reads its password through credsReadCommand, which this repo does not declare (e.g. 'my-secrets {field} {name}')`)
216
+ // without {field} the account read answers the VALUE, and the run types the
217
+ // password into the identity box, where a failed-login journal keeps it
218
+ if (c.smoke?.login?.emailSecret != null && !/\{field\}/.test(c.credsReadCommand ?? ''))
219
+ throw new Error(`defineRepo: smoke.login.emailSecret needs credsReadCommand to carry {field}, or the account read answers the password itself`)
185
220
  return c
186
221
  }
187
222
 
@@ -203,6 +238,12 @@ export async function loadRepoConfig(root: string): Promise<RepoConfig> {
203
238
  // so a key would be risk against no reward. It is not `never:` either: the
204
239
  // contract dirs describe it perfectly well, so claiming nothing can is false,
205
240
  // and `never` has to keep meaning "somebody looked and no key exists".
241
+ //
242
+ // Declaring `inputs: ['.']` to clear its burn-down row was TRIED and reverted:
243
+ // a keyable contracts task writes a ledger entry inside every fixture repo,
244
+ // which is six ledger tests asserting an entry count that is really about the
245
+ // task under test. The row it leaves behind is the honest cost of the model
246
+ // having no third state between "nobody looked" and "no key can exist".
206
247
  export function contractsTask(c: RepoConfig, root: string): TaskDef {
207
248
  return {
208
249
  id: 'contracts',
@@ -0,0 +1,50 @@
1
+ // SK014 — the STE prose standard, in the gate (identity/writing-voice.md).
2
+ //
3
+ // Opt-in by declaration: a repo with no `prose.include` is SKIPPED, not
4
+ // failed. STE is house doctrine for prose an agent writes, and a rule that
5
+ // turned every existing repo red the day it shipped would be turned off
6
+ // rather than obeyed.
7
+ //
8
+ // The tier split is the whole design. `fix` and `hard` hits BLOCK: they are
9
+ // mechanical, and a machine reporting them is never guessing. `soft` hits
10
+ // (sentence length, passive voice) need a human read, so they WARN, and only
11
+ // a declared `baseline` makes them blocking, as a ratchet that can only go
12
+ // down.
13
+ //
14
+ // It carries NO `fix()` on purpose. `shipkit ci` runs `--fix-rules` by
15
+ // default, so a rule with a fixer rewrites tracked files unattended, and
16
+ // prose is not repo shape: a wrong package.json line is a config, a wrong
17
+ // sentence is content. The rewrite lives behind an explicit `shipkit prose
18
+ // --fix` that a human ran and can read the diff of.
19
+ import { classSummary, proseLines, runProseCheck } from '../prose/run-prose.ts'
20
+ import type { Rule } from './rule.ts'
21
+
22
+ const OWNER = 'identity/writing-voice.md'
23
+
24
+ export const proseSte: Rule = {
25
+ id: 'SK014',
26
+ blocking: true,
27
+ title: 'STE prose',
28
+ owner: OWNER,
29
+ inputs: ['.'],
30
+ lint: (t, env) => {
31
+ const cfg = env.config.prose
32
+ if (cfg == null || (cfg.include ?? []).length === 0) {
33
+ t.ok(`no prose scope declared — add \`prose: { include: [...] }\` to repo.config.ts to gate house prose on STE`)
34
+ return
35
+ }
36
+ const r = runProseCheck({ root: env.root, config: cfg })
37
+ const blocking = r.byTier.fix + r.byTier.hard
38
+ for (const l of proseLines(r, { cap: 12, tiers: ['fix', 'hard'] })) t.note(l)
39
+ if (blocking > 0)
40
+ t.fail(
41
+ `${blocking} STE violation(s) across ${r.byFile.length}/${r.files} file(s) — ${classSummary(r)}`,
42
+ r.fixable > 0 ? `shipkit prose --fix rewrites ${r.fixable} of them` : undefined,
43
+ )
44
+ if (cfg.baseline != null && r.byTier.soft > cfg.baseline)
45
+ t.fail(`${r.byTier.soft} soft hits (sentence length, passive voice), above the declared baseline of ${cfg.baseline} — the ratchet only goes down`)
46
+ else if (r.byTier.soft > 0)
47
+ t.warn(`${r.byTier.soft} soft hit(s): sentence length and passive voice need a human read${cfg.baseline != null ? ` (baseline ${cfg.baseline})` : ''}`)
48
+ if (!t.failed && blocking === 0) t.ok(`${r.files} file(s) STE-clean on the mechanical rules`)
49
+ }
50
+ }
@@ -16,6 +16,7 @@ import { undeclaredReleaseUnit } from './SK010-undeclared-release-unit.ts'
16
16
  import { siblingUnitRanges } from './SK011-sibling-unit-ranges.ts'
17
17
  import { configBarrelImport } from './SK012-config-barrel-import.ts'
18
18
  import { oneWayInPolicy } from './SK013-one-way-in-policy.ts'
19
+ import { proseSte } from './SK014-prose-ste.ts'
19
20
 
20
21
  export const RULES: readonly Rule[] = [
21
22
  packageJsonShape,
@@ -31,6 +32,7 @@ export const RULES: readonly Rule[] = [
31
32
  siblingUnitRanges,
32
33
  configBarrelImport,
33
34
  oneWayInPolicy,
35
+ proseSte,
34
36
  ]
35
37
 
36
38
  // THE id shape, one place: 2-4 uppercase letters + exactly 3 digits. `SK` is
@@ -31,13 +31,16 @@ export const GENERATED_IGNORE_LINES = [
31
31
  'STATUS.md',
32
32
  'STATUS.md.lock',
33
33
  '.STATUS.md.tmp-*',
34
+ 'STATUS.full.md',
35
+ 'STATUS.full.md.lock',
36
+ '.STATUS.full.md.tmp-*',
34
37
  ]
35
38
 
36
39
  /** the LITERAL subset an autofix may remove from the index. Never a glob and
37
40
  * never derived from a pattern: `git rm --cached` driven by a wildcard is how
38
41
  * a fixer eats source it was never pointed at. A future entry is added HERE,
39
42
  * by hand, spelled out. */
40
- export const GENERATED_TRACKED_PATHS = ['STATUS.md', 'STATUS.md.lock'] as const
43
+ export const GENERATED_TRACKED_PATHS = ['STATUS.md', 'STATUS.md.lock', 'STATUS.full.md', 'STATUS.full.md.lock'] as const
41
44
 
42
45
  export type UntrackOutcome =
43
46
  | { did: 'nothing' }