@haus-tech/haus-workflow 4.5.0 → 4.7.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,334 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Mekanisk efterhandskontroll av en classify- eller score-JSON mot sitt
4
+ * underlag. Fångar det starka modeller ändå slarvar med i lång kontext:
5
+ * parafraserade "ordagranna" citat, läckta protokolltermer i kundtext, brutna
6
+ * enum- och upplösningsregler. Bedömer aldrig innehållets rimlighet — bara
7
+ * kontraktet. Vid BROTT: dispatcha agenten om EN gång med felraderna, sedan
8
+ * stopp. VARNING är heuristik (t.ex. ankar-id som krockar med pappersformat) —
9
+ * visas, stoppar inte.
10
+ *
11
+ * Användning: node contract-check.mjs <run.json> <spec.md> [impact.md]
12
+ * Exit 0 = ok, 1 = kontraktsbrott (listade), 2 = användningsfel.
13
+ */
14
+ import { readFileSync } from 'node:fs'
15
+
16
+ import { refDriftWarning } from './lib.mjs'
17
+
18
+ const argv = process.argv.slice(2)
19
+ let expectId = null
20
+ const idFlag = argv.indexOf('--expect-id')
21
+ if (idFlag !== -1) {
22
+ expectId = argv[idFlag + 1]
23
+ argv.splice(idFlag, 2)
24
+ }
25
+ const [jsonPath, specPath, impactPath] = argv
26
+ if (!jsonPath || !specPath) {
27
+ console.error(
28
+ 'användning: node contract-check.mjs <run.json> <spec.md> [impact.md] [--expect-id <id>]',
29
+ )
30
+ process.exit(2)
31
+ }
32
+
33
+ const problems = []
34
+ const warnings = []
35
+
36
+ // Kontraktsdriftvakt: enums, upplösning och svartlista nedan SPEGLAR reglerna
37
+ // i referensfilerna. Pinsen är markör-strippade (stabila över haus-releaser,
38
+ // som stämplar om filer med paketversionen) och sökvägarna hedrar
39
+ // CLAUDE_CONFIG_DIR — se lib.mjs.
40
+ const EXPECTED_REFS = {
41
+ 'request-classify/references/klassningsinstruktion.md':
42
+ '811f0bff62c749255e86da7ea8db0ea868ebc9df14262f9889101c110d9d14ff',
43
+ 'request-score/references/poangskala.md':
44
+ 'cb5a6c221b5d39c445ca31a427fd3f96c648388ac5441c22ea2ea5f72e1f8e31',
45
+ }
46
+ for (const [rel, expected] of Object.entries(EXPECTED_REFS)) {
47
+ const w = refDriftWarning(rel, expected)
48
+ if (w) warnings.push(w)
49
+ }
50
+ // Markdown-betoning (**fet**, *kursiv*, `kod`) strippas före jämförelse — ett
51
+ // citat som återges utan källans markeringar är fortfarande ordagrant.
52
+ const norm = (s) => s.replace(/[*`]/g, '').replace(/\s+/g, ' ').trim()
53
+ let doc
54
+ try {
55
+ doc = JSON.parse(readFileSync(jsonPath, 'utf8'))
56
+ } catch (e) {
57
+ console.log(`BROTT: ${jsonPath} är inte giltig JSON (${e.message})`)
58
+ process.exit(1)
59
+ }
60
+ // Oläsbart underlag är dispatcherns fel (exit 2), aldrig ett kontraktsbrott —
61
+ // exit 1 skulle trigga en meningslös och dyr omdispatch av agenten.
62
+ let source
63
+ try {
64
+ source = norm(
65
+ readFileSync(specPath, 'utf8') + '\n' + (impactPath ? readFileSync(impactPath, 'utf8') : ''),
66
+ )
67
+ } catch (e) {
68
+ console.error(`kunde inte läsa underlaget: ${e.message}`)
69
+ process.exit(2)
70
+ }
71
+
72
+ /** Ett "ordagrant" citat får hoppa med [...] och kortas med inledande/avslutande …
73
+ * — varje kvarvarande segment måste vara exakt substräng av underlaget.
74
+ * Ett citat som blir tomt efter filtreringen är INTE ordagrant (annars vore
75
+ * "[...]" ett giltigt belägg för vad som helst). */
76
+ const quoteOk = (q) => {
77
+ const segments = q
78
+ .split(/\s*\[(?:\.\.\.|…)\]\s*/)
79
+ .map((seg) => norm(seg.replace(/^(\.\.\.|…)|(\.\.\.|…)$/g, '')))
80
+ .filter((seg) => seg.length > 0)
81
+ return segments.length > 0 && segments.every((seg) => source.includes(seg))
82
+ }
83
+
84
+ /** Alla strängvärden ur ett godtyckligt värde (drivers kan vara objekt). */
85
+ const collectStrings = (v) => {
86
+ if (typeof v === 'string') return [v]
87
+ if (Array.isArray(v)) return v.flatMap(collectStrings)
88
+ if (v && typeof v === 'object') return Object.values(v).flatMap(collectStrings)
89
+ return []
90
+ }
91
+
92
+ // Protokollmekanik som aldrig får nå kundtext. Klass/klassas är kundspråk
93
+ // (kunden ser Class-fältet) och KONCEPT/DIAGNOS är legitima kundutfall — de
94
+ // står medvetet INTE här. Poäng och skalans mekanik är interna. Gränserna är
95
+ // Unicode-medvetna (JS \b bryter vid å/ä/ö), och "protokoll" är snävat så att
96
+ // vardagssvenska som "protokollförs" går fri medan klassningsprotokollet fångas.
97
+ const B = (pat) => new RegExp(`(?<![\\p{L}\\d_])(?:${pat})`, 'iu')
98
+ const CUSTOMER_BLACKLIST = [
99
+ B('poäng\\p{L}*'),
100
+ B('basklass\\p{L}*'),
101
+ B('bump\\p{L}*'),
102
+ B('ankar\\p{L}*'),
103
+ B('referensuppdrag\\p{L}*'),
104
+ B('inringning\\p{L}*'),
105
+ B('konfidens\\p{L}*'),
106
+ // Substantivformer av skalan är mekanik; verbet "skalar" (skalbarhet) är
107
+ // legitim kundsvenska och går fritt.
108
+ B('skal(?:a|an|ans|or|orna)(?!\\p{L})'),
109
+ B('protokoll(?:et|en|s)?(?!\\p{L})'),
110
+ /klassningsprotokoll/iu,
111
+ B('blast\\s*radius'),
112
+ B('touchpoint\\p{L}*'),
113
+ // u-flaggan är obligatorisk: utan den är \p{L} i lookaround teckenklassen
114
+ // "p{L" och "era B2B-kunder" blir ett falskt BROTT.
115
+ /(?<![\p{L}\d_])[SB][1-3](?![\p{L}\d_])/u,
116
+ ]
117
+ // Ankar-id (A1–A17) kolliderar strukturellt med pappersformat (A3/A4/A5) och
118
+ // går inte att regexa isär — heuristik, alltså varning i stället för brott.
119
+ const ANCHOR_RE = /(?<![\p{L}\d_])A(?:1[0-7]|[1-9])(?![\p{L}\d_])/u
120
+ const checkCustomerText = (text, field) => {
121
+ if (typeof text !== 'string') return
122
+ for (const re of CUSTOMER_BLACKLIST) {
123
+ const m = text.match(re)
124
+ if (m) problems.push(`${field}: protokollterm "${m[0]}" i kundtext`)
125
+ }
126
+ const a = text.match(ANCHOR_RE)
127
+ if (a) warnings.push(`${field}: "${a[0]}" — ankar-id eller pappersformat? Kontrollera`)
128
+ }
129
+
130
+ /** Poängupplösning: 0.25-steg under 3, heltal från 3, alltid > 0.
131
+ * Multiplar av 0.25 är exakta i binär flyttal, så *4-testet är exakt. */
132
+ const checkScoreValue = (score, field) => {
133
+ if (typeof score !== 'number') {
134
+ problems.push(`${field}: saknas eller är inte ett tal`)
135
+ return
136
+ }
137
+ if (score <= 0) problems.push(`${field}: ${score} — poäng måste vara större än 0`)
138
+ else if (score < 3 ? Math.round(score * 4) !== score * 4 : !Number.isInteger(score))
139
+ problems.push(`${field}: ${score} bryter upplösningen (0.25-steg under 3, heltal från 3)`)
140
+ }
141
+ const checkBracket = (b, field) => {
142
+ // Under-A1-fallet är kontraktsenligt golvlöst: "Får du inget golv: posten är
143
+ // mindre än A1 och får A1:s poäng" — då är floor null och taket A1.
144
+ const floorless = b?.floor == null && b?.ceiling?.anchor === 'A1'
145
+ if (
146
+ !(floorless || b?.floor?.anchor) ||
147
+ !b?.ceiling?.anchor ||
148
+ !Array.isArray(b?.between) ||
149
+ !Array.isArray(b?.steps)
150
+ ) {
151
+ problems.push(
152
+ `${field}: ofullständig inringning (floor/ceiling/between/steps krävs; undantag: floor null när taket är A1) — en poäng utan inringning är inte ett giltigt utfall`,
153
+ )
154
+ return
155
+ }
156
+ if (!floorless && b.between.length !== 2)
157
+ problems.push(
158
+ `${field}.between: ${b.between.length} element (kräver exakt 2 angränsande ankare)`,
159
+ )
160
+ if (b.steps.length === 0)
161
+ problems.push(`${field}.steps: tom — inringningen ska redovisa ankarjämförelserna`)
162
+ }
163
+
164
+ const MODES = ['pricing', 'guard']
165
+ const BASKLASS = ['TINY', 'SMALL', 'MEDIUM', 'LARGE', 'OVER']
166
+
167
+ // Kuverten är slutna: agenterna får aldrig lägga till egna nycklar ("ordagrant
168
+ // och orört"). Okänd toppnyckel är ett brott, inte en utökning.
169
+ const ENVELOPE_KEYS = {
170
+ classify: ['request_id', 'mode', 'classification', 'customer', 'guard_diff'],
171
+ score: [
172
+ 'request_id',
173
+ 'mode',
174
+ 'outcome',
175
+ 'score',
176
+ 'type',
177
+ 'bracket',
178
+ 'drivers',
179
+ 'confidence',
180
+ 'conditions',
181
+ 'assumptions_stated',
182
+ 'alternatives',
183
+ 'dependencies',
184
+ 'notes',
185
+ 'customer_rationale',
186
+ 'guard_diff',
187
+ ],
188
+ }
189
+ const checkEnvelopeKeys = (kind) => {
190
+ for (const key of Object.keys(doc))
191
+ if (!ENVELOPE_KEYS[kind].includes(key))
192
+ problems.push(`okänd toppnyckel "${key}" — kuvertet är slutet, egna nycklar är förbjudna`)
193
+ }
194
+
195
+ if (doc.classification !== undefined) {
196
+ // ---- classify ------------------------------------------------------------
197
+ const UTFALL = [
198
+ 'TINY',
199
+ 'SMALL',
200
+ 'MEDIUM',
201
+ 'LARGE',
202
+ 'OFFERT',
203
+ 'KONCEPT',
204
+ 'DIAGNOS',
205
+ 'TILLBAKA_TILL_SPEC',
206
+ 'INPUT_SAKNAS',
207
+ 'ESKALERA',
208
+ ]
209
+ // Trasig form är brottet, aldrig en krasch — utan '- '-rader har omdispatchen
210
+ // inget att citera och stacktracen ser ut som ett scriptfel.
211
+ const c = doc.classification && typeof doc.classification === 'object' ? doc.classification : null
212
+ if (c === null) {
213
+ console.log('BROTT (1):')
214
+ console.log('- classification: null eller inte ett objekt — kuvertet är trasigt')
215
+ process.exit(1)
216
+ }
217
+ checkEnvelopeKeys('classify')
218
+ if (!MODES.includes(doc.mode)) problems.push(`mode: ogiltigt värde "${doc.mode}"`)
219
+ if (!UTFALL.includes(c.utfall))
220
+ problems.push(`classification.utfall: ogiltigt värde "${c.utfall}"`)
221
+ if (c.basklass != null && !BASKLASS.includes(c.basklass))
222
+ problems.push(`classification.basklass: ogiltigt värde "${c.basklass}"`)
223
+ // En klassning utan frågerader har ingen belägglogik alls — tom lista är
224
+ // samma brott som tomt belägg. Undantag: INPUT_SAKNAS stoppar i Steg 0,
225
+ // FÖRE frågorna — då är kravet i stället en icke-tom saknas-lista.
226
+ if (c.utfall === 'INPUT_SAKNAS') {
227
+ if (!Array.isArray(c.saknas) || c.saknas.length === 0)
228
+ problems.push(
229
+ 'classification.saknas: tom trots INPUT_SAKNAS — utfallet ska lista vad som saknas',
230
+ )
231
+ } else if (!Array.isArray(c.fragor) || c.fragor.length === 0) {
232
+ problems.push(
233
+ 'classification.fragor: saknas eller är tom — varje körning ska redovisa sina frågerader',
234
+ )
235
+ }
236
+ const fragorRows = Array.isArray(c.fragor) ? c.fragor : []
237
+ for (const f0 of fragorRows) {
238
+ if (f0 == null || typeof f0 !== 'object') {
239
+ problems.push('fragor: en rad är null eller inte ett objekt')
240
+ continue
241
+ }
242
+ const f = f0
243
+ // S1–S3/B1–B3 svarar JA/NEJ; basklassraden svarar med en klass
244
+ // (instruktionens JSON, "ordagrant och orört" — tvinga aldrig om den).
245
+ if (/^[SB][1-3]$/i.test(f.id ?? '')) {
246
+ if (!['JA', 'NEJ'].includes(f.svar))
247
+ problems.push(`fråga ${f.id}: svar "${f.svar}" (kräver JA/NEJ)`)
248
+ } else if (!BASKLASS.includes(f.svar) && !['JA', 'NEJ'].includes(f.svar)) {
249
+ problems.push(`fråga ${f.id}: svar "${f.svar}" (kräver basklassvärde eller JA/NEJ)`)
250
+ }
251
+ if (typeof f.konfidens !== 'number' || f.konfidens < 0 || f.konfidens > 1)
252
+ problems.push(`fråga ${f.id}: konfidens ${f.konfidens} utanför 0–1`)
253
+ // Belägg är obligatoriskt per instruktionen — saknat belägg är brottet,
254
+ // inte låg kvalitet.
255
+ if (!f.belagg || norm(f.belagg).length === 0)
256
+ problems.push(`fråga ${f.id}: belägg saknas (obligatoriskt, ordagrant citat)`)
257
+ else if (!quoteOk(f.belagg))
258
+ problems.push(
259
+ `fråga ${f.id}: belägg är inte ordagrant citat ur underlaget: "${f.belagg.slice(0, 70)}..."`,
260
+ )
261
+ }
262
+ checkCustomerText(doc.customer?.rationale, 'customer.rationale')
263
+ for (const [i, cond] of (doc.customer?.conditions ?? []).entries())
264
+ checkCustomerText(cond, `customer.conditions[${i}]`)
265
+ for (const [i, dep] of (doc.customer?.dependencies ?? []).entries())
266
+ checkCustomerText(dep, `customer.dependencies[${i}]`)
267
+ } else if (doc.outcome !== undefined) {
268
+ // ---- score ---------------------------------------------------------------
269
+ const OUTCOMES = [
270
+ 'POANG',
271
+ 'KONCEPT',
272
+ 'DIAGNOS',
273
+ 'TILLBAKA_TILL_SPEC',
274
+ 'DELA_UPP',
275
+ 'OVER_SKALAN',
276
+ 'INPUT_SAKNAS',
277
+ ]
278
+ checkEnvelopeKeys('score')
279
+ if (!MODES.includes(doc.mode)) problems.push(`mode: ogiltigt värde "${doc.mode}"`)
280
+ if (!OUTCOMES.includes(doc.outcome)) problems.push(`outcome: ogiltigt värde "${doc.outcome}"`)
281
+ if (!['hog', 'medel', 'lag'].includes(doc.confidence))
282
+ problems.push(`confidence: ogiltigt värde "${doc.confidence}"`)
283
+ if (doc.outcome === 'POANG') {
284
+ checkScoreValue(doc.score, 'score')
285
+ checkBracket(doc.bracket, 'bracket')
286
+ if (!Array.isArray(doc.drivers) || doc.drivers.length < 2 || doc.drivers.length > 3)
287
+ problems.push(
288
+ `drivers: ${Array.isArray(doc.drivers) ? doc.drivers.length : 'saknas'} (skillen kräver två eller tre faktorer)`,
289
+ )
290
+ } else if (doc.score != null) {
291
+ problems.push(`score: ska vara null när outcome är ${doc.outcome}`)
292
+ }
293
+ // Citat i drivers (även i objektform) ska vara ordagranna ur underlaget.
294
+ // Både raka och typografiska citattecken räknas. Ett icke-array-värde är
295
+ // redan fällt av POANG-kontrollen ovan — här skyddar vi bara iterationen.
296
+ for (const [i, d] of (Array.isArray(doc.drivers) ? doc.drivers : []).entries())
297
+ for (const str of collectStrings(d))
298
+ for (const m of str.matchAll(/["“”„«»]([^"“”„«»]{15,})["“”„«»]/g))
299
+ if (!quoteOk(m[1]))
300
+ problems.push(
301
+ `drivers[${i}]: citat är inte ordagrant ur underlaget: "${m[1].slice(0, 70)}..."`,
302
+ )
303
+ checkCustomerText(doc.customer_rationale, 'customer_rationale')
304
+ for (const [i, cond] of (doc.conditions ?? []).entries())
305
+ checkCustomerText(cond, `conditions[${i}]`)
306
+ for (const [i, a] of (doc.assumptions_stated ?? []).entries())
307
+ checkCustomerText(a, `assumptions_stated[${i}]`)
308
+ // Alternativa lösningar bär samma kontrakt som huvudpoängen: kundspråk i
309
+ // label, giltig upplösning, och "ett alternativ utan inringning är lika
310
+ // ogiltigt som en post utan".
311
+ for (const [i, alt] of (doc.alternatives ?? []).entries()) {
312
+ checkCustomerText(alt?.label, `alternatives[${i}].label`)
313
+ checkCustomerText(alt?.adds, `alternatives[${i}].adds`)
314
+ if (alt?.score != null) {
315
+ checkScoreValue(alt.score, `alternatives[${i}].score`)
316
+ checkBracket(alt?.bracket, `alternatives[${i}].bracket`)
317
+ }
318
+ }
319
+ } else {
320
+ problems.push('okänd dokumenttyp: varken classification (classify) eller outcome (score)')
321
+ }
322
+
323
+ // Kuvertets request_id ska höra till katalogen det arkiveras i.
324
+ if (expectId != null && doc.request_id !== expectId)
325
+ problems.push(`request_id: "${doc.request_id}" matchar inte förväntat "${expectId}"`)
326
+
327
+ for (const w of warnings) console.log(`VARNING: ${w}`)
328
+ if (problems.length === 0) {
329
+ console.log('OK: kontraktet håller')
330
+ process.exit(0)
331
+ }
332
+ console.log(`BROTT (${problems.length}):`)
333
+ for (const p of problems) console.log(`- ${p}`)
334
+ process.exit(1)
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Delade byggstenar för requestmotorns kontrollscript. En kopia av varje regel:
3
+ * driftvakterna, dispatcher-rälsen för compare-scripten och hashningen bor här,
4
+ * så en härdning aldrig kan landa i ena scriptet och missa det andra.
5
+ */
6
+ import { readFileSync } from 'node:fs'
7
+ import { createHash } from 'node:crypto'
8
+ import { homedir } from 'node:os'
9
+ import { join, resolve } from 'node:path'
10
+
11
+ /**
12
+ * Claude-konfigkatalogen. Installern hedrar CLAUDE_CONFIG_DIR (#278) — det ska
13
+ * scripten också göra, annars läser driftvakten fel eller kvarlämnat träd på
14
+ * relokerade maskiner.
15
+ */
16
+ export function claudeDir() {
17
+ return process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude')
18
+ }
19
+
20
+ /**
21
+ * Tar bort haus ägarmarkör ur filinnehåll före hashning. Installern stämplar
22
+ * varje fil med paketversionen i markören, så en hash ÖVER markören byter värde
23
+ * vid varje release utan att innehållet ändrats — driftvakten skulle bli
24
+ * permanent brus och guardens skevhetsvägran skulle ogiltigförklara alla
25
+ * arkiverade körningar vid varje haus-uppgradering. Strippningen är idempotent
26
+ * och en no-op på ostämplade filer.
27
+ */
28
+ export function stripHausMarker(content) {
29
+ return content
30
+ .split('\n')
31
+ .filter(
32
+ (line, i) =>
33
+ !(
34
+ line.startsWith('<!-- HAUS-MANAGED') ||
35
+ ((i === 0 || i === 1) && line.startsWith('// HAUS-MANAGED')) ||
36
+ /^haus_managed:\s/.test(line)
37
+ ),
38
+ )
39
+ .join('\n')
40
+ }
41
+
42
+ /** sha256 (hex) över markör-strippat filinnehåll. */
43
+ export function markerStrippedSha256(path) {
44
+ return createHash('sha256')
45
+ .update(stripHausMarker(readFileSync(path, 'utf8')))
46
+ .digest('hex')
47
+ }
48
+
49
+ /**
50
+ * Kontraktsdriftvakt: jämför en referensfils markör-strippade hash mot den pin
51
+ * scriptet byggdes mot. Returnerar en varningssträng eller null — anroparen
52
+ * väljer kanal (precheck/contract-check samlar i sin rapport, compare-scripten
53
+ * skriver till stderr så verdiktraden på stdout förblir ren).
54
+ */
55
+ export function refDriftWarning(relSkillPath, expectedSha) {
56
+ const p = join(claudeDir(), 'skills', relSkillPath)
57
+ try {
58
+ if (markerStrippedSha256(p) !== expectedSha)
59
+ return `referensfilen ${p} har ändrats sedan scriptet skrevs — se över att kontrollerna fortfarande speglar den`
60
+ } catch {
61
+ return `referensfilen ${p} gick inte att läsa för driftkontroll`
62
+ }
63
+ return null
64
+ }
65
+
66
+ /**
67
+ * Dispatcher-räls för compare-scripten: argv, samma-fil-vägran, läs+parse,
68
+ * byteidentisk-vägran och mode-kontroll. Exit 2 = jämförelsen kan inte göras
69
+ * (dispatcherns fel, aldrig LIKA). Returnerar { a, b } (hela dokumenten).
70
+ */
71
+ export function loadRunPair(usage) {
72
+ const [aPath, bPath] = process.argv.slice(2)
73
+ if (!aPath || !bPath) {
74
+ console.error(usage)
75
+ process.exit(2)
76
+ }
77
+ if (resolve(aPath) === resolve(bPath)) {
78
+ console.error('samma fil två gånger — driftvakten kräver två oberoende körningar')
79
+ process.exit(2)
80
+ }
81
+ let rawA, rawB, a, b
82
+ try {
83
+ rawA = readFileSync(aPath, 'utf8')
84
+ rawB = readFileSync(bPath, 'utf8')
85
+ a = JSON.parse(rawA)
86
+ b = JSON.parse(rawB)
87
+ } catch (e) {
88
+ console.error(`kunde inte läsa körningarna: ${e.message}`)
89
+ process.exit(2)
90
+ }
91
+ if (rawA === rawB) {
92
+ console.error(
93
+ 'filerna är byteidentiska — två oberoende körningar är aldrig det; fel fil skickad?',
94
+ )
95
+ process.exit(2)
96
+ }
97
+ if (a.mode !== 'pricing' || b.mode !== 'pricing') {
98
+ console.error(`båda körningarna måste vara mode pricing (fick "${a.mode}" och "${b.mode}")`)
99
+ process.exit(2)
100
+ }
101
+ return { a, b }
102
+ }
103
+
104
+ /** Skriver LIKA (exit 0) eller OLIKA + diffrader (exit 1) på stdout. */
105
+ export function reportDiffs(diffs) {
106
+ if (diffs.length === 0) {
107
+ console.log('LIKA')
108
+ process.exit(0)
109
+ }
110
+ console.log(`OLIKA (${diffs.length}):`)
111
+ for (const d of diffs) console.log(`- ${d}`)
112
+ process.exit(1)
113
+ }
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Mekanisk förhandskontroll av en requestpost i arkivet, före dyra
4
+ * bedömningskörningar. Kontrollerar ENDAST form, aldrig innehåll — en post som
5
+ * passerar kan fortfarande stoppas av protokollen (TILLBAKA_TILL_SPEC m.fl.),
6
+ * och det är deras jobb, inte det här scriptets. Returnerar därför ett eget
7
+ * status (ok/fel-lista), aldrig ett protokollutfall.
8
+ *
9
+ * BRISTER (exit 1) är hårda formfel. VARNING är heuristik som kräver mänsklig
10
+ * blick (klassord i analys, saknad Ingår inte) — de skrivs ut men stoppar inget.
11
+ *
12
+ * Användning: node precheck.mjs <arkivkatalog> [--impact]
13
+ * --impact kräv och kontrollera även impact.md (bedöm-steget)
14
+ * Exit 0 = ok, 1 = brister (listade på stdout), 2 = användningsfel.
15
+ */
16
+ import { readFileSync, existsSync } from 'node:fs'
17
+ import { join } from 'node:path'
18
+
19
+ import { markerStrippedSha256, refDriftWarning } from './lib.mjs'
20
+
21
+ const dir = process.argv[2]
22
+ const requireImpact = process.argv.includes('--impact')
23
+ if (!dir) {
24
+ console.error('användning: node precheck.mjs <arkivkatalog> [--impact]')
25
+ process.exit(2)
26
+ }
27
+
28
+ const problems = []
29
+ const warnings = []
30
+
31
+ // Kontraktsdriftvakt: rubrik- och vokabulärreglerna nedan speglar specformatet.
32
+ // Pinnen är markör-strippad (stabil över haus-releaser, som stämplar om filer
33
+ // med paketversionen) och sökvägen hedrar CLAUDE_CONFIG_DIR — se lib.mjs.
34
+ const SPECFORMAT_SHA = '53bbb92ad6337557901e827c97a0995ff92150f88e0ede2810a28f36b51aa17c'
35
+ {
36
+ const w = refDriftWarning('request-write-spec/references/specformat.md', SPECFORMAT_SHA)
37
+ if (w) warnings.push(w)
38
+ }
39
+
40
+ // Tidsord förbjuds i både spec och analys — EN regex, så reglerna aldrig glider isär.
41
+ const TIDSORD_RE = /[\p{L}]*tim(?:me|mar|marna|men)[\p{L}]*|[\p{L}]*estim(?:at|er)[\p{L}]*/iu
42
+
43
+ // JS \b bryter vid å/ä/ö — svenska ord kräver Unicode-medvetna gränser
44
+ // ("kräver" får aldrig matcha förbudet "kr").
45
+ const W = (pat) => new RegExp(`(?<![\\p{L}\\d_])(?:${pat})(?![\\p{L}\\d_])`, 'iu')
46
+ // Rubrikrad: fetstilsrad eller markdown-rubrik som utgör hela raden.
47
+ const isHeadingLine = (line) =>
48
+ /^\*\*[^*]+\*\*\s*$/.test(line.trim()) || /^#{1,6}\s+\S/.test(line.trim())
49
+ const headingRe = (h) => new RegExp(`^(\\*\\*${h}\\*\\*|#{1,6}\\s*${h})\\s*$`, 'm')
50
+ /** Raderna i sektionen under rubriken `heading`, fram till nästa rubrikrad. */
51
+ const sectionLines = (block, heading) => {
52
+ const lines = block.split('\n')
53
+ const start = lines.findIndex((l) => headingRe(heading).test(l.trim()))
54
+ if (start === -1) return null
55
+ const rest = lines.slice(start + 1)
56
+ const end = rest.findIndex((l) => isHeadingLine(l))
57
+ return rest.slice(0, end === -1 ? rest.length : end)
58
+ }
59
+
60
+ const read = (name, required) => {
61
+ const p = join(dir, name)
62
+ if (!existsSync(p)) {
63
+ if (required) problems.push(`${name}: saknas`)
64
+ return null
65
+ }
66
+ return readFileSync(p, 'utf8')
67
+ }
68
+
69
+ // ---- spec.md ---------------------------------------------------------------
70
+ const spec = read('spec.md', true)
71
+ if (spec != null) {
72
+ // En katalog = en spec. Specformatet föreskriver ingen separator mellan
73
+ // specar, så bunt-detektionen räknar den obligatoriska Problem-rubriken över
74
+ // hela filen — fler än en betyder att en bunt inte delats i egna kataloger,
75
+ // och då klassas två beslut som en post (fel pris åt något håll).
76
+ const problemCount = (spec.match(new RegExp(headingRe('Problem').source, 'gm')) ?? []).length
77
+ if (problemCount > 1)
78
+ problems.push(
79
+ `spec.md: ${problemCount} "Problem"-rubriker i en katalog — en spec per katalog, dela bunten först`,
80
+ )
81
+ const blocks = spec
82
+ .split(/\n---\n/)
83
+ .map((b) => b.trim())
84
+ .filter(Boolean)
85
+ if (blocks.length === 0) problems.push('spec.md: tom')
86
+ blocks.forEach((block, i) => {
87
+ const label = blocks.length > 1 ? `spec.md block ${i + 1}` : 'spec.md'
88
+ // Obligatoriskt per specformatet: Problem, Lösning, När det är klart.
89
+ // Ingår inte är valfri i formatet ("ibland ingen alls") — saknad blir varning.
90
+ for (const heading of ['Problem', 'Lösning', 'När det är klart'])
91
+ if (!headingRe(heading).test(block)) problems.push(`${label}: rubriken "${heading}" saknas`)
92
+ if (!headingRe('Ingår inte').test(block))
93
+ warnings.push(`${label}: "Ingår inte" saknas — ofta rätt att ringa in vad som inte ingår`)
94
+ // 2–5 numrerade punkter under När det är klart, avgränsat vid nästa
95
+ // rubrikrad (inte vid första ** — fetstil inuti en punkt är tillåten).
96
+ const sect = sectionLines(block, 'När det är klart')
97
+ if (sect != null) {
98
+ const items = sect.filter((l) => /^\d+\.\s/.test(l.trim()))
99
+ if (items.length < 2 || items.length > 5)
100
+ problems.push(`${label}: "När det är klart" har ${items.length} punkter (kräver 2–5)`)
101
+ for (const it of items)
102
+ if (W('eller').test(it))
103
+ problems.push(`${label}: "eller" i utfallspunkt: ${it.trim().slice(0, 60)}`)
104
+ }
105
+ })
106
+ // Förbjuden vokabulär i hela specen (tid, pengar, hedge-ord). Tidsord matchas
107
+ // utan inledande gräns så sammansättningar (arbetstimmar, halvtimme) fälls.
108
+ // "kr" kräver siffra före sig — annars fälls identifierare som "KR-2".
109
+ const banned = ['förslagsvis', 'alternativt', 'beroende på', 'en lämplig', 'kronor', 'SEK']
110
+ for (const word of banned) {
111
+ const m = spec.match(W(word))
112
+ if (m) problems.push(`spec.md: förbjudet ord "${m[0]}"`)
113
+ }
114
+ // Bindestreck friar identifierare ("punkt 3: KR-2" är inget belopp).
115
+ const kr = spec.match(/\d[\s.,:]*kr(?![\p{L}\d_-])/iu)
116
+ if (kr) problems.push(`spec.md: belopp "${kr[0].trim()}"`)
117
+ const time = spec.match(TIDSORD_RE)
118
+ if (time) problems.push(`spec.md: förbjudet tidsord "${time[0]}"`)
119
+ }
120
+
121
+ // ---- granskarblock.md ------------------------------------------------------
122
+ const gb = read('granskarblock.md', true)
123
+ if (gb != null) {
124
+ if (!gb.includes('## Till granskaren'))
125
+ problems.push('granskarblock.md: rubriken "## Till granskaren" saknas')
126
+ for (const row of [
127
+ 'Antaganden',
128
+ 'Vägval jag gjort',
129
+ 'Öppna utfallsval',
130
+ 'Flaggor',
131
+ 'Struket ur underlaget',
132
+ ])
133
+ if (!gb.includes(`**${row}:**`)) problems.push(`granskarblock.md: raden "**${row}:**" saknas`)
134
+ }
135
+
136
+ // ---- impact.md (bedöm-steget) ---------------------------------------------
137
+ if (requireImpact) {
138
+ const impact = read('impact.md', true)
139
+ if (impact != null) {
140
+ const headings = [
141
+ 'Berörda repon och filer',
142
+ 'Datalagring',
143
+ 'Integrationspunkter',
144
+ 'Flöden',
145
+ 'Gränssnitt',
146
+ 'Verifiering',
147
+ 'Delbarhet',
148
+ 'Konsekvens av fel',
149
+ 'Vägval',
150
+ 'Antaganden',
151
+ 'Beroenden',
152
+ ]
153
+ for (const h of headings)
154
+ if (!headingRe(h).test(impact)) problems.push(`impact.md: rubriken "${h}" saknas`)
155
+ if (!impact.includes('## Till granskaren'))
156
+ problems.push('impact.md: "## Till granskaren" saknas')
157
+ for (const row of ['Ej verifierat', 'Avvikelse mot specen', 'Obesvarade utfallsval'])
158
+ if (!impact.includes(`**${row}:**`)) problems.push(`impact.md: raden "**${row}:**" saknas`)
159
+ // Analysen får inte läcka klass eller tid. Klassord är dock en heuristik —
160
+ // "felklassen", "den klassen (av ändringar)" och ett ordagrant citerat
161
+ // kod-enum (MEDIUM/LARGE i ett repo) är legitima — så träffar blir
162
+ // varningar för mänsklig blick, aldrig hårda stopp. Tidsord förblir hårda.
163
+ const m = impact.match(W('klass(?:er|en|ning\\p{L}*)?'))
164
+ if (m)
165
+ warnings.push(
166
+ `impact.md: klassord "${m[0]}" — kontrollera att analysen inte läcker klassning`,
167
+ )
168
+ const cls = impact.match(/(?<![\p{L}\d_])(TINY|SMALL|MEDIUM|LARGE|OFFERT)(?![\p{L}\d_])/u)
169
+ if (cls)
170
+ warnings.push(
171
+ `impact.md: klassnamnet "${cls[0]}" — läcka, eller citat av ett kod-enum? Kontrollera`,
172
+ )
173
+ const t = impact.match(TIDSORD_RE)
174
+ if (t) problems.push(`impact.md: förbjudet tidsord "${t[0]}"`)
175
+ }
176
+ }
177
+
178
+ // ---- state.json ------------------------------------------------------------
179
+ const state = read('state.json', true)
180
+ if (state != null) {
181
+ try {
182
+ const s = JSON.parse(state)
183
+ for (const key of ['request_id', 'slug', 'title', 'status', 'runs'])
184
+ if (!(key in s)) problems.push(`state.json: fältet "${key}" saknas`)
185
+ // Underlagsproveniens: analysen måste höra till DEN spec som ligger i
186
+ // katalogen nu. En respec efter underkännande lämnar annars en gammal
187
+ // analys som prissätts mot ny spec — helt osynligt utan den här kontrollen.
188
+ if (requireImpact && spec != null && Array.isArray(s.runs)) {
189
+ const impactRun = [...s.runs].reverse().find((r) => r.file === 'impact.md')
190
+ const recorded = impactRun?.underlag_sha256?.['spec.md']
191
+ if (recorded == null) {
192
+ // Tystnad här är exakt skevheten grinden finns för — varna alltid när
193
+ // analysen inte kan knytas till specen, oavsett varför.
194
+ warnings.push(
195
+ impactRun
196
+ ? 'state.json: analysens runs-rad saknar underlag_sha256 — kan inte verifiera att analysen hör till nuvarande spec'
197
+ : 'state.json: impact.md finns men ingen runs-rad för den — kan inte verifiera att analysen hör till nuvarande spec',
198
+ )
199
+ } else if (recorded !== markerStrippedSha256(join(dir, 'spec.md'))) {
200
+ problems.push(
201
+ 'impact.md: analysen gjordes mot en annan version av spec.md — kör om analysen innan posten bedöms',
202
+ )
203
+ }
204
+ }
205
+ } catch {
206
+ problems.push('state.json: ogiltig JSON')
207
+ }
208
+ }
209
+
210
+ for (const w of warnings) console.log(`VARNING: ${w}`)
211
+ if (problems.length === 0) {
212
+ console.log('OK: formkontrollen passerad')
213
+ process.exit(0)
214
+ }
215
+ console.log(`BRISTER (${problems.length}):`)
216
+ for (const p of problems) console.log(`- ${p}`)
217
+ process.exit(1)
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Skriver den markör-strippade sha256:an för en fil — den enda hash som är
3
+ * stabil över haus-releaser (installern stämplar om filer med paketversionen).
4
+ * Används av /request-wrappern för state.json:s reference_sha256 och
5
+ * underlag_sha256, och är samma hashdefinition som driftvakterna i lib.mjs.
6
+ *
7
+ * Användning: node ref-hash.mjs <fil>
8
+ * Exit 0 + hex-hash på stdout; 2 = användningsfel/oläsbar fil.
9
+ */
10
+ import { markerStrippedSha256 } from './lib.mjs'
11
+
12
+ const [file] = process.argv.slice(2)
13
+ if (!file) {
14
+ console.error('användning: node ref-hash.mjs <fil>')
15
+ process.exit(2)
16
+ }
17
+ try {
18
+ console.log(markerStrippedSha256(file))
19
+ } catch (e) {
20
+ console.error(`kunde inte läsa ${file}: ${e.message}`)
21
+ process.exit(2)
22
+ }