@haus-tech/haus-workflow 5.0.1 → 5.2.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,640 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Scorerns kontrollkedja i EN process (plan requestmotor-korning, Task 1):
4
+ * svansrader, JSON-extraktion, deterministiska reparationer, check-lasta,
5
+ * resolve-anchors och contract-check, med valfri fältomskrivning av kundtext.
6
+ * Ersätter fyra separata anrop (och fyra orkestratorturer) per scorerkörning.
7
+ *
8
+ * Reparationerna ändrar aldrig poäng, utfall eller belägg: de rättar det
9
+ * dispatchern redan vet (request_id), form på enumvärden och siffersträngar,
10
+ * och LÄSTA-radens sökvägsform. Varje reparation skrivs som REPARERAT-rad och
11
+ * bokförs av anroparen i runs-radens note. Allt annat är modellfel och går
12
+ * till omdispatch enligt ADR-0092.
13
+ *
14
+ * Fältomskrivning (--fixa-kundtext): ett protokollterm-brott i ett rent
15
+ * kundtextfält kostar idag en hel scorerkörning, som dessutom kan ge en ny
16
+ * poäng, alltså brus i dubbelkörningen. Här skrivs bara fältet om av en
17
+ * billig modell (claude -p --model sonnet --max-turns 1) och kedjan körs om,
18
+ * högst MAX_FIXRUNDOR gånger. Råsvaren sparas via --fix-fil. Fixen är en
19
+ * valfri besparing: faller anropet (inget claude på PATH, exit skilt från 0,
20
+ * ogiltig JSON, is_error, tomt result) loggas en rad på stderr och kedjans
21
+ * ursprungliga BROTT står kvar, så posten går till omdispatch som vanligt.
22
+ * En brusten fix får aldrig fälla den obligatoriska kontrollkedjan.
23
+ *
24
+ * Användning:
25
+ * node kontroll.mjs <svar.txt> <spec.md> <impact.md> --expect-id <id> --ut <ren.json>
26
+ * [--fixa-kundtext] [--claude <kommando>] [--fix-modell sonnet] [--fix-fil <rå.json>]
27
+ * [--state <state.json>] [--sprak <svenska|engelska>]
28
+ * `--state` och `--sprak` går rakt vidare till contract-check (split-status,
29
+ * #503; kundtextens språk, #521).
30
+ * stdout: MODELL:, LÄSTA:, [AVVIKELSE:], REPARERAT: (0..n), kedjans rader, sist
31
+ * "OK: kontrollkedjan håller" (exit 0) eller "BROTT (n):" + rader (exit 1).
32
+ * Exit 2 = användningsfel eller ett delscript med exit 2.
33
+ */
34
+ import { readFileSync, writeFileSync, realpathSync } from 'node:fs'
35
+ import { spawnSync } from 'node:child_process'
36
+ import { homedir } from 'node:os'
37
+ import path from 'node:path'
38
+ import { fileURLToPath } from 'node:url'
39
+
40
+ import {
41
+ parseSvansrader,
42
+ extraheraJson,
43
+ SCORE_OUTCOMES,
44
+ MODES,
45
+ STEP_RELATIONS,
46
+ KONFIDENS,
47
+ TYPER,
48
+ } from './lib.mjs'
49
+
50
+ const HAR = path.dirname(fileURLToPath(import.meta.url))
51
+ export const MAX_FIXRUNDOR = 1
52
+ const VIK = { hög: 'hog', låg: 'lag', större: 'storre' }
53
+ // coverage_post.yta går genom checkCustomerText i contract-check som varje
54
+ // annat kundnära fält (ADR-0119). Saknades det här tvingade ett flaggat ord i
55
+ // täckningsposten en betald omdispatch i stället för omskrivningen (#517).
56
+ const KUNDTEXT_RE =
57
+ /^- (customer_rationale|conditions\[\d+\]|assumptions_stated\[\d+\]|alternatives\[\d+\]\.(?:label|adds)|coverage_post\.yta): protokollterm "([^"]+)" i kundtext$/u
58
+
59
+ // --sprak (#521) går rakt vidare till contract-check. Språkbrottet den ger
60
+ // matchar med flit inte KUNDTEXT_RE: kundtextfixen byter ett ord, den
61
+ // översätter inte, så brottet går till omdispatch.
62
+ const VARDEFLAGGOR = ['expect-id', 'ut', 'claude', 'fix-modell', 'fix-fil', 'state', 'sprak']
63
+ const BOOLFLAGGOR = ['fixa-kundtext']
64
+
65
+ export const ANVANDNING = [
66
+ 'användning: kontroll.mjs <svar.txt> <spec.md> <impact.md> --expect-id <id> --ut <ren.json>',
67
+ ' [--fixa-kundtext] [--claude <kommando>] [--fix-modell sonnet] [--fix-fil <rå.json>]',
68
+ ' [--state <state.json>] [--sprak <svenska|engelska>]',
69
+ ].join('\n')
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Rena hjälpare (testas in-process)
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /** Argumenttolkning, fail-closed som svep.mjs tolkaArgv: alla saknade
76
+ * flaggor namnges på en gång, en okänd flagga eller ett värdelöst
77
+ * flaggvärde stoppar direkt i stället för att gissa. */
78
+ export function tolkaArgv(argv) {
79
+ const positionella = []
80
+ const enkla = {}
81
+ const bool = { 'fixa-kundtext': false }
82
+ for (let i = 0; i < argv.length; i++) {
83
+ const a = argv[i]
84
+ if (!a.startsWith('--')) {
85
+ positionella.push(a)
86
+ continue
87
+ }
88
+ const namn = a.slice(2)
89
+ if (BOOLFLAGGOR.includes(namn)) {
90
+ bool[namn] = true
91
+ continue
92
+ }
93
+ if (!VARDEFLAGGOR.includes(namn)) return { fel: `okänd flagga: --${namn}` }
94
+ const v = argv[i + 1]
95
+ if (v === undefined || v.startsWith('--')) return { fel: `--${namn} kräver ett värde` }
96
+ i++
97
+ enkla[namn] = v
98
+ }
99
+ if (positionella.length !== 3)
100
+ return {
101
+ fel: `förväntade tre positionella sökvägar (svar.txt spec.md impact.md), fick ${positionella.length}`,
102
+ }
103
+ const saknas = ['expect-id', 'ut'].filter((n) => enkla[n] === undefined)
104
+ if (saknas.length > 0) return { fel: `saknar ${saknas.map((n) => `--${n}`).join(', ')}` }
105
+ return {
106
+ svarPath: positionella[0],
107
+ specPath: positionella[1],
108
+ impactPath: positionella[2],
109
+ expectId: enkla['expect-id'],
110
+ utPath: enkla.ut,
111
+ fixaKundtext: bool['fixa-kundtext'],
112
+ claudeCmd: enkla.claude ?? 'claude',
113
+ fixModell: enkla['fix-modell'] ?? 'sonnet',
114
+ fixFil: enkla['fix-fil'] ?? null,
115
+ statePath: enkla.state ?? null,
116
+ sprak: enkla.sprak ?? null,
117
+ }
118
+ }
119
+
120
+ const enumForm = (v, tillatna, { versal = false } = {}) => {
121
+ if (typeof v !== 'string') return null
122
+ let k = v.trim()
123
+ k = versal ? k.toUpperCase() : k.toLowerCase()
124
+ if (!versal && VIK[k]) k = VIK[k]
125
+ return tillatna.includes(k) && k !== v ? k : null
126
+ }
127
+ const talForm = (v) =>
128
+ typeof v === 'string' && /^\d+([.,]\d+)?$/.test(v.trim())
129
+ ? Number(v.trim().replace(',', '.'))
130
+ : null
131
+
132
+ const typForm = (v) => {
133
+ if (typeof v !== 'string') return null
134
+ const k = v.trim().toLowerCase()
135
+ const traff = TYPER.find((t) => t.toLowerCase() === k)
136
+ return traff !== undefined && traff !== v ? traff : null
137
+ }
138
+
139
+ // Ankarnycklarnas alias: modellen skriver ibland den svenska stavningen
140
+ // (samma ord som i prosan) i stället för resolve-anchors.mjs:s ASCII-schema
141
+ // (samma stil som kalla/hog/storre). Bara nyckeln byts, aldrig värdet.
142
+ const ANKAR_NYCKEL_ALIAS = [
143
+ ['delsträng', 'delstrang'],
144
+ ['källa', 'kalla'],
145
+ ]
146
+ // De två fält resolve-anchors.mjs:s walk() letar efter på valfri nivå i
147
+ // dokumentet (MAPPINGS i resolve-anchors.mjs): ankare (poängens drivers) och
148
+ // belagg_ankare (klassaxelns frågerader, kvar för äldre arkiv).
149
+ const ANKAR_FALT = ['ankare', 'belagg_ankare']
150
+
151
+ /** Normaliserar ankarobjektens nycklar var resolve-anchors.mjs än letar
152
+ * efter dem (samma fält, samma djup som dess walk()). Bara när ASCII-
153
+ * nyckeln saknas; värdet kopieras ordagrant, aldrig rört. */
154
+ const normaliseraAnkarnycklar = (node, sokvag, rader) => {
155
+ if (Array.isArray(node))
156
+ return node.map((v, i) => normaliseraAnkarnycklar(v, `${sokvag}[${i}]`, rader))
157
+ if (node === null || typeof node !== 'object') return node
158
+ const ut = {}
159
+ for (const [nyckel, varde] of Object.entries(node)) {
160
+ const barnvag = sokvag ? `${sokvag}.${nyckel}` : nyckel
161
+ if (
162
+ ANKAR_FALT.includes(nyckel) &&
163
+ varde &&
164
+ typeof varde === 'object' &&
165
+ !Array.isArray(varde)
166
+ ) {
167
+ let a = { ...varde }
168
+ for (const [gammal, ny] of ANKAR_NYCKEL_ALIAS) {
169
+ if (a[gammal] !== undefined && a[ny] === undefined) {
170
+ rader.push(`${barnvag}: nyckeln "${gammal}" normaliserad till "${ny}"`)
171
+ const { [gammal]: flyttat, ...rest } = a
172
+ a = { ...rest, [ny]: flyttat }
173
+ }
174
+ }
175
+ ut[nyckel] = a
176
+ } else {
177
+ ut[nyckel] = normaliseraAnkarnycklar(varde, barnvag, rader)
178
+ }
179
+ }
180
+ return ut
181
+ }
182
+
183
+ const stegRelationer = (bracket, prefix, rader) => {
184
+ if (!Array.isArray(bracket?.steps)) return bracket
185
+ const steps = bracket.steps.map((s, i) => {
186
+ const r = enumForm(s?.relation, STEP_RELATIONS)
187
+ if (r === null) return s
188
+ rader.push(`${prefix}steps[${i}].relation "${s.relation}" normaliserad till "${r}"`)
189
+ return { ...s, relation: r }
190
+ })
191
+ return { ...bracket, steps }
192
+ }
193
+
194
+ /** Ny doc med reparationerna applicerade plus en rad per reparation. */
195
+ export function reparera(doc, { expectId }) {
196
+ const rader = []
197
+ let d = { ...doc }
198
+ if (expectId != null && d.request_id !== expectId) {
199
+ rader.push(`request_id "${d.request_id}" ersatt med "${expectId}" (dispatchern vet id:t)`)
200
+ d = { ...d, request_id: expectId }
201
+ }
202
+ const o = enumForm(d.outcome, SCORE_OUTCOMES, { versal: true })
203
+ if (o !== null) {
204
+ rader.push(`outcome "${d.outcome}" normaliserad till "${o}"`)
205
+ d = { ...d, outcome: o }
206
+ }
207
+ const m = enumForm(d.mode, MODES)
208
+ if (m !== null) {
209
+ rader.push(`mode "${d.mode}" normaliserad till "${m}"`)
210
+ d = { ...d, mode: m }
211
+ }
212
+ const c = enumForm(d.confidence, KONFIDENS)
213
+ if (c !== null) {
214
+ rader.push(`confidence "${d.confidence}" normaliserad till "${c}"`)
215
+ d = { ...d, confidence: c }
216
+ }
217
+ // type (#514 p1): översiktstabellens fem värden, exakt modulo skiftläge och
218
+ // blanksteg. Fritext hängd på värdet är inte reparerbar och faller i
219
+ // contract-check, avsiktligt.
220
+ const t = typForm(d.type)
221
+ if (t !== null) {
222
+ rader.push(`type "${d.type}" normaliserad till "${t}"`)
223
+ d = { ...d, type: t }
224
+ }
225
+ const s = talForm(d.score)
226
+ if (s !== null) {
227
+ rader.push(`score "${d.score}" tolkad som talet ${s}`)
228
+ d = { ...d, score: s }
229
+ }
230
+ if (d.bracket) d = { ...d, bracket: stegRelationer(d.bracket, 'bracket.', rader) }
231
+ if (Array.isArray(d.alternatives))
232
+ d = {
233
+ ...d,
234
+ alternatives: d.alternatives.map((alt, j) => {
235
+ let a = alt
236
+ const as = talForm(a?.score)
237
+ if (as !== null) {
238
+ rader.push(`alternatives[${j}].score "${a.score}" tolkad som talet ${as}`)
239
+ a = { ...a, score: as }
240
+ }
241
+ if (a?.bracket)
242
+ a = { ...a, bracket: stegRelationer(a.bracket, `alternatives[${j}].bracket.`, rader) }
243
+ return a
244
+ }),
245
+ }
246
+ const ankarRader = []
247
+ const dAnkare = normaliseraAnkarnycklar(d, '', ankarRader)
248
+ if (ankarRader.length > 0) {
249
+ d = dAnkare
250
+ rader.push(...ankarRader)
251
+ }
252
+ return { doc: d, reparerat: rader }
253
+ }
254
+
255
+ /** LÄSTA-radens sökvägsform: ~ expanderas, avslutande skiljetecken strippas,
256
+ * relativa sökvägar löses mot underlagets katalog. Scopet prövas av
257
+ * check-lasta, aldrig här. */
258
+ export function normaliseraLasta(lasta, basDir) {
259
+ if (lasta == null || lasta.trim() === '' || lasta.trim() === 'inga') return 'inga'
260
+ return lasta
261
+ .split(',')
262
+ .map((s) => s.trim().replace(/[.,;]+$/u, ''))
263
+ .filter(Boolean)
264
+ .map((p) => (p.startsWith('~/') ? path.join(homedir(), p.slice(2)) : p))
265
+ .map((p) => (path.isAbsolute(p) ? p : path.resolve(basDir, p)))
266
+ .join(', ')
267
+ }
268
+
269
+ /** Brotten som är rena kundtextbrott: alla eller inga. Ett enda brott av
270
+ * annan sort betyder att en fältomskrivning inte räcker. */
271
+ export function kundtextBrott(brottRader) {
272
+ const ut = []
273
+ for (const rad of brottRader) {
274
+ const m = KUNDTEXT_RE.exec(rad.trim())
275
+ if (!m) return []
276
+ ut.push({ falt: m[1], term: m[2] })
277
+ }
278
+ return ut
279
+ }
280
+
281
+ /** Ny doc med fältet (customer_rationale, conditions[n], assumptions_stated[n],
282
+ * alternatives[n].label|adds, coverage_post.yta) ersatt. Ett punktfält utan
283
+ * index är ett objektfält (ADR-0119): bara undernyckeln byts, syskonen
284
+ * (omfattning) står kvar. */
285
+ export function ersattFalt(doc, falt, text) {
286
+ const m = /^(\w+)(?:\[(\d+)\])?(?:\.(\w+))?$/u.exec(falt)
287
+ if (!m) throw new Error(`okänt fält: ${falt}`)
288
+ const [, namn, idx, under] = m
289
+ if (idx === undefined && under !== undefined)
290
+ return { ...doc, [namn]: { ...(doc[namn] ?? {}), [under]: text } }
291
+ if (idx === undefined) return { ...doc, [namn]: text }
292
+ const lista = [...(doc[namn] ?? [])]
293
+ const i = Number(idx)
294
+ lista[i] = under === undefined ? text : { ...lista[i], [under]: text }
295
+ return { ...doc, [namn]: lista }
296
+ }
297
+
298
+ /** Läser fältets nuvarande text, motsvarigheten till ersattFalt fast läsande.
299
+ * Samma fältgrammatik: customer_rationale, conditions[n],
300
+ * assumptions_stated[n], alternatives[n].label|adds, coverage_post.yta. */
301
+ export function hamtaFalt(doc, falt) {
302
+ const m = /^(\w+)(?:\[(\d+)\])?(?:\.(\w+))?$/u.exec(falt)
303
+ if (!m) throw new Error(`okänt fält: ${falt}`)
304
+ const [, namn, idx, under] = m
305
+ if (idx === undefined && under !== undefined) return doc[namn]?.[under]
306
+ if (idx === undefined) return doc[namn]
307
+ const post = (doc[namn] ?? [])[Number(idx)]
308
+ return under === undefined ? post : post?.[under]
309
+ }
310
+
311
+ export function omskrivningsPrompt(text, term) {
312
+ return [
313
+ 'Skriv om följande kundtext så att ordet "' +
314
+ term +
315
+ '" (och böjningar av det) inte förekommer.',
316
+ 'Behåll innebörden och längden. Använd inga ord som beskriver hur ett pris räknats fram',
317
+ '(poäng, ankare, skala, referensuppdrag, inringning, konfidens, klass, protokoll).',
318
+ 'Svara med enbart den omskrivna texten, utan citattecken och utan kommentar.',
319
+ '',
320
+ text,
321
+ ].join('\n')
322
+ }
323
+
324
+ /** Läser och parsar en JSON-fil. { data } eller { fel }, aldrig ett kastat
325
+ * undantag, så anroparen kan bokföra felet som ett dispatcherfel (exit 2). */
326
+ function lasJson(path) {
327
+ try {
328
+ return { data: JSON.parse(readFileSync(path, 'utf8')) }
329
+ } catch (e) {
330
+ return { fel: `kunde inte läsa ${path}: ${e.message}` }
331
+ }
332
+ }
333
+
334
+ /** Skriver en JSON-fil (två mellanslags indrag, avslutande radbrytning).
335
+ * null vid lyckad skrivning, annars ett felmeddelande. */
336
+ function skrivJson(path, data) {
337
+ try {
338
+ writeFileSync(path, JSON.stringify(data, null, 2) + '\n')
339
+ return null
340
+ } catch (e) {
341
+ return `kunde inte skriva ${path}: ${e.message}`
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Tolkar ett `claude -p --output-format json`-svar från kundtextfixen.
347
+ * { text } bara när CLI:n svarade utan fel OCH `result` är en icke-tom sträng.
348
+ * Utan den kontrollen skrevs `undefined` eller tomma strängen rakt in i
349
+ * kundtextfältet: en tyst radering av kundens motivering, som dessutom
350
+ * passerar kedjan (ingen protokollterm i en tom sträng).
351
+ */
352
+ export function tolkaOmskrivning(parsed) {
353
+ if (parsed?.is_error === true)
354
+ return { fel: `CLI:n rapporterade is_error (${String(parsed.result ?? '').slice(0, 200)})` }
355
+ const text = parsed?.result
356
+ if (typeof text !== 'string') return { fel: 'svaret saknar en result-sträng' }
357
+ if (text.trim() === '') return { fel: 'svarets result är tomt' }
358
+ return { text }
359
+ }
360
+
361
+ /** Skriver om ETT kundtextfält via en billig `claude -p`. { fel }, eller
362
+ * { d, svar, rad }: ny doc med fältet ersatt, råsvaret (för --fix-fil) och
363
+ * REPARERAT-raden. */
364
+ function omskrivFalt({ d, falt, term, claudeCmd, fixModell }) {
365
+ const svar = spawnSync(
366
+ claudeCmd,
367
+ ['-p', '--model', fixModell, '--output-format', 'json', '--max-turns', '1'],
368
+ { input: omskrivningsPrompt(hamtaFalt(d, falt), term), encoding: 'utf8' },
369
+ )
370
+ // spawnSync lämnar stdout och stderr som null när kommandot inte finns på
371
+ // PATH, så `(svar.stderr || svar.stdout).trim()` hade kastat här.
372
+ if (svar.error)
373
+ return { fel: `${claudeCmd}: omskrivning av ${falt} startade inte (${svar.error.message})` }
374
+ if (svar.status !== 0) {
375
+ const text = (svar.stderr || svar.stdout || '').trim() || `exit ${svar.status}`
376
+ return { fel: `${claudeCmd}: omskrivning av ${falt} misslyckades (${text})` }
377
+ }
378
+ let parsed
379
+ try {
380
+ parsed = JSON.parse(svar.stdout)
381
+ } catch (e) {
382
+ return { fel: `${claudeCmd}: ogiltigt JSON-svar vid omskrivning av ${falt}: ${e.message}` }
383
+ }
384
+ const tolkad = tolkaOmskrivning(parsed)
385
+ if (tolkad.fel) return { fel: `${claudeCmd}: omskrivning av ${falt}: ${tolkad.fel}` }
386
+ return {
387
+ d: ersattFalt(d, falt, tolkad.text),
388
+ svar: parsed,
389
+ rad: `${falt} omskrivet av ${fixModell} (protokollterm "${term}")`,
390
+ }
391
+ }
392
+
393
+ /** Ett varvs alla fältomskrivningar. { fel } vid det första misslyckade
394
+ * anropet, och dokumentet lämnas då orört: en halvskriven runda får aldrig
395
+ * nå disk. */
396
+ function skrivOmFalten(doc, brott, { claudeCmd, fixModell }) {
397
+ let d = doc
398
+ const rader = []
399
+ const svar = []
400
+ for (const { falt, term } of brott) {
401
+ const fix = omskrivFalt({ d, falt, term, claudeCmd, fixModell })
402
+ if (fix.fel) return { fel: fix.fel }
403
+ d = fix.d
404
+ svar.push(fix.svar)
405
+ rader.push(fix.rad)
406
+ }
407
+ return { d, rader, svar }
408
+ }
409
+
410
+ /**
411
+ * Upp till MAX_FIXRUNDOR varv av: läs --ut-filens aktuella skick, skriv om
412
+ * varje kundtextfält som bär ett brott via omskrivFalt, skriv --ut igen, kör
413
+ * kedjan om. Anropas bara när ALLA kvarvarande brott är kundtextbrott
414
+ * (anroparens ansvar).
415
+ *
416
+ * En misslyckad omskrivning är INTE ett dispatcherfel: fixen är en valfri
417
+ * besparing ovanpå den obligatoriska kedjan, och fälldes den som exit 2 kunde
418
+ * ett saknat `claude` på PATH slå ut hela kontrollkedjan. Då loggas en rad på
419
+ * stderr och kedjans ursprungliga BROTT returneras, så anroparen går den
420
+ * vanliga omdispatchvägen. Kvar som { fel } (exit 2) är bara läs- och
421
+ * skrivfel mot --ut-filen och ett delscript med exit 2.
422
+ */
423
+ function fixaKundtextrundor({ startResultat, utPath, claudeCmd, fixModell, kedja }) {
424
+ let resultat = startResultat
425
+ const reparatRader = []
426
+ const raSvar = []
427
+ let rundor = 0
428
+ while (
429
+ resultat.brott.length > 0 &&
430
+ kundtextBrott(resultat.brott).length > 0 &&
431
+ rundor < MAX_FIXRUNDOR
432
+ ) {
433
+ // resolve-anchors kan ha skrivit om --ut-filen (lösta citat) sedan den
434
+ // senaste skrivningen, så aktuellt skick läses från disk.
435
+ const last = lasJson(utPath)
436
+ if (last.fel) return { fel: `${last.fel} (inför fältomskrivning)` }
437
+ const runda = skrivOmFalten(last.data, kundtextBrott(resultat.brott), { claudeCmd, fixModell })
438
+ if (runda.fel) {
439
+ process.stderr.write(
440
+ `kundtextfix misslyckades: ${runda.fel}, faller tillbaka på omdispatch\n`,
441
+ )
442
+ return { resultat, reparatRader, raSvar }
443
+ }
444
+ const skrivFel = skrivJson(utPath, runda.d)
445
+ if (skrivFel) return { fel: skrivFel }
446
+ reparatRader.push(...runda.rader)
447
+ raSvar.push(...runda.svar)
448
+ rundor++
449
+ resultat = kedja()
450
+ if (resultat.exit2) return { fel: resultat.exit2 }
451
+ }
452
+ return { resultat, reparatRader, raSvar }
453
+ }
454
+
455
+ // check-lasta.mjs och contract-check.mjs skriver sina brott som "- ...".
456
+ // resolve-anchors.mjs skriver "OLÖST: ..."/"FLERTYDIG: ..." (inget
457
+ // bindestreck), samma sorts brott, bara en annan yta. Normaliserar båda
458
+ // formerna till "- <raden>" så BROTT-blocket alltid har en enhetlig radform.
459
+ const brottFranRader = (rader) =>
460
+ rader
461
+ .filter((l) => l.startsWith('- ') || l.startsWith('OLÖST:') || l.startsWith('FLERTYDIG:'))
462
+ .map((l) => (l.startsWith('- ') ? l : `- ${l}`))
463
+
464
+ /**
465
+ * Bygger `kor`/`kedja` (barnprocesserna check-lasta/resolve-anchors/contract-
466
+ * check), kör kedjan en gång, och vid `--fixa-kundtext` med enbart
467
+ * kundtextbrott (kundtextBrott returnerar tomt om ens ett brott är av annan
468
+ * sort, den vanliga BROTT-vägen tar då över i stället) delegerar till
469
+ * fixaKundtextrundor för resten. Returnerar { fel } vid ett dispatcherfel
470
+ * (exit 2 hos anroparen), annars { resultat, reparatRader, raSvar } där
471
+ * resultat är kedjans SISTA utfall.
472
+ */
473
+ function korKedjaOchFixa({
474
+ utPath,
475
+ specPath,
476
+ impactPath,
477
+ lastaNorm,
478
+ expectId,
479
+ fixaKundtext,
480
+ claudeCmd,
481
+ fixModell,
482
+ statePath,
483
+ sprak,
484
+ }) {
485
+ const kor = (script, args) =>
486
+ spawnSync(process.execPath, [path.join(HAR, script), ...args], { encoding: 'utf8' })
487
+ const kedja = () => {
488
+ const brott = []
489
+ const utskrift = []
490
+ // Vid ett olöst/flertydigt ankare lämnar resolve-anchors --ut-filen med
491
+ // citat obesatt. contract-check kan då bara ge exit 2 (citat saknas,
492
+ // "kör resolve-anchors först"), vilket kontroll.mjs annars felaktigt
493
+ // läser som ett dispatcherfel i stället för det retrybara BROTT det
494
+ // egentligen är. Kedjan stannar därför här, kör aldrig contract-check
495
+ // på det skicket.
496
+ for (const [script, args, stoppaVidBrott] of [
497
+ ['check-lasta.mjs', [lastaNorm, specPath, impactPath], false],
498
+ ['resolve-anchors.mjs', [utPath, specPath, impactPath], true],
499
+ [
500
+ 'contract-check.mjs',
501
+ [
502
+ utPath,
503
+ specPath,
504
+ impactPath,
505
+ '--expect-id',
506
+ expectId,
507
+ ...(statePath ? ['--state', statePath] : []),
508
+ ...(sprak ? ['--sprak', sprak] : []),
509
+ ],
510
+ false,
511
+ ],
512
+ ]) {
513
+ const r = kor(script, args)
514
+ if (r.status === 2) return { exit2: `${script}: ${(r.stderr || r.stdout).trim()}` }
515
+ const rader = r.stdout.split('\n').filter(Boolean)
516
+ utskrift.push(...rader)
517
+ if (r.status !== 0) {
518
+ brott.push(...brottFranRader(rader))
519
+ if (stoppaVidBrott) return { brott, utskrift }
520
+ }
521
+ }
522
+ return { brott, utskrift }
523
+ }
524
+
525
+ let resultat = kedja()
526
+ if (resultat.exit2) return { fel: resultat.exit2 }
527
+
528
+ const reparatRader = []
529
+ let raSvar = []
530
+ if (fixaKundtext && resultat.brott.length > 0 && kundtextBrott(resultat.brott).length > 0) {
531
+ const fix = fixaKundtextrundor({ startResultat: resultat, utPath, claudeCmd, fixModell, kedja })
532
+ if (fix.fel) return { fel: fix.fel }
533
+ resultat = fix.resultat
534
+ reparatRader.push(...fix.reparatRader)
535
+ raSvar = fix.raSvar
536
+ }
537
+ return { resultat, reparatRader, raSvar }
538
+ }
539
+
540
+ /** Skriver MODELL/LÄSTA/[AVVIKELSE]-raderna och sist verdikten: antingen det
541
+ * egna BROTT (1) när svaret aldrig gav JSON (`ingenJson`), eller
542
+ * REPARERAT-raderna, kedjans egna rader ordagrant och OK/BROTT. Returnerar
543
+ * exit-koden. */
544
+ function skrivUtfall({ modell, lastaNorm, avvikelse, ingenJson, reparatRader, resultat }) {
545
+ console.log(`MODELL: ${modell ?? '-'}`)
546
+ console.log(`LÄSTA: ${lastaNorm}`)
547
+ if (avvikelse) console.log(`AVVIKELSE: ${avvikelse}`)
548
+ if (ingenJson) {
549
+ console.log('BROTT (1):')
550
+ console.log('- svaret innehåller ingen JSON')
551
+ return 1
552
+ }
553
+ for (const r of reparatRader) console.log(`REPARERAT: ${r}`)
554
+ for (const l of resultat.utskrift) console.log(l)
555
+ if (resultat.brott.length === 0) {
556
+ console.log('OK: kontrollkedjan håller')
557
+ return 0
558
+ }
559
+ console.log(`BROTT (${resultat.brott.length}):`)
560
+ for (const b of resultat.brott) console.log(b)
561
+ return 1
562
+ }
563
+
564
+ /** Läser svar.txt, delar svansraderna och extraherar JSON. { fel } vid ett
565
+ * läsfel, annars { modell, lastaNorm, avvikelse, raw } där raw är null när
566
+ * svaret aldrig gav JSON. */
567
+ function lasSvar(svarPath, specPath) {
568
+ let svarText
569
+ try {
570
+ svarText = readFileSync(svarPath, 'utf8')
571
+ } catch (e) {
572
+ return { fel: `kunde inte läsa ${svarPath}: ${e.message}` }
573
+ }
574
+ const { body, modell, lasta, avvikelse } = parseSvansrader(svarText)
575
+ return {
576
+ modell,
577
+ lastaNorm: normaliseraLasta(lasta, path.dirname(specPath)),
578
+ avvikelse,
579
+ raw: extraheraJson(body),
580
+ }
581
+ }
582
+
583
+ // ---------------------------------------------------------------------------
584
+ // main (körs bara direkt, se arDirektKord längst ner)
585
+ // ---------------------------------------------------------------------------
586
+
587
+ export function main(argv) {
588
+ const val = tolkaArgv(argv)
589
+ if (val.fel) {
590
+ console.error(val.fel)
591
+ console.error(ANVANDNING)
592
+ return 2
593
+ }
594
+
595
+ const inlast = lasSvar(val.svarPath, val.specPath)
596
+ if (inlast.fel) {
597
+ console.error(inlast.fel)
598
+ return 2
599
+ }
600
+ const { modell, lastaNorm, avvikelse, raw } = inlast
601
+ if (raw === null) return skrivUtfall({ modell, lastaNorm, avvikelse, ingenJson: true })
602
+
603
+ const { doc: reparerad, reparerat } = reparera(raw, { expectId: val.expectId })
604
+ const skrivFel = skrivJson(val.utPath, reparerad)
605
+ if (skrivFel) {
606
+ console.error(skrivFel)
607
+ return 2
608
+ }
609
+
610
+ const kf = korKedjaOchFixa({ ...val, lastaNorm })
611
+ if (kf.fel) {
612
+ console.error(kf.fel)
613
+ return 2
614
+ }
615
+
616
+ if (val.fixFil && kf.raSvar.length > 0) {
617
+ const fixFilFel = skrivJson(val.fixFil, kf.raSvar)
618
+ if (fixFilFel) {
619
+ console.error(fixFilFel)
620
+ return 2
621
+ }
622
+ }
623
+
624
+ return skrivUtfall({
625
+ modell,
626
+ lastaNorm,
627
+ avvikelse,
628
+ reparatRader: [...reparerat, ...kf.reparatRader],
629
+ resultat: kf.resultat,
630
+ })
631
+ }
632
+
633
+ const arDirektKord = () => {
634
+ try {
635
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
636
+ } catch {
637
+ return false
638
+ }
639
+ }
640
+ if (arDirektKord()) process.exit(main(process.argv.slice(2)))