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