@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.
- package/CHANGELOG.md +19 -0
- package/library/catalog/manifest.json +67 -61
- package/library/global/agents/request-assessor.md +22 -6
- package/library/global/agents/request-scorer.md +3 -1
- package/library/global/agents/request-specifier-eskalerad.md +8 -0
- package/library/global/agents/request-specifier.md +8 -3
- package/library/global/skills/request/SKILL.md +700 -221
- package/library/global/skills/request/scripts/archive.mjs +920 -59
- package/library/global/skills/request/scripts/compare-scores.mjs +119 -13
- package/library/global/skills/request/scripts/contract-check.mjs +302 -40
- package/library/global/skills/request/scripts/kontroll.mjs +640 -0
- package/library/global/skills/request/scripts/korning.mjs +1635 -0
- package/library/global/skills/request/scripts/lib.mjs +541 -5
- package/library/global/skills/request/scripts/precheck.mjs +148 -5
- package/library/global/skills/request/scripts/report.mjs +122 -16
- package/library/global/skills/request/scripts/svep.mjs +737 -0
- package/library/global/skills/request-assess-impact/SKILL.md +58 -4
- package/library/global/skills/request-score/SKILL.md +57 -11
- package/library/global/skills/request-score/references/poangskala.md +3 -0
- package/library/global/skills/request-sync/SKILL.md +11 -12
- package/library/global/skills/request-write-spec/SKILL.md +13 -4
- package/library/global/skills/request-write-spec/references/specformat.md +48 -0
- package/package.json +1 -1
|
@@ -3,17 +3,31 @@
|
|
|
3
3
|
* MEDIUM+). Poängen har större driftyta än klassens enum och multipliceras
|
|
4
4
|
* direkt till pris — request-score:s eget acceptanstest är att två oberoende
|
|
5
5
|
* körningar ger samma poäng. Jämförelsefälten är outcome, score och
|
|
6
|
-
* inringningens golv- och takankare
|
|
7
|
-
*
|
|
6
|
+
* inringningens golv- och takankare, samt type (toleransrad), antalet
|
|
7
|
+
* conditions (hård diff) och alternatives per label (#509). Prosa (why,
|
|
8
|
+
* drivers, rationale) jämförs ALDRIG. Confidence (enum, inte prosa) visas
|
|
9
|
+
* alltid i utskriften, men fäller
|
|
8
10
|
* bara ett par utanför toleransen, se nedan.
|
|
9
11
|
*
|
|
10
12
|
* Toleransregel: en poängskillnad ≤ 0,5 med samma outcome är prisbrus, inte
|
|
11
13
|
* oenighet, oavsett confidence. En ankarskillnad tolereras dessutom bara när
|
|
12
14
|
* banden överlappar eller angränsar; icke-angränsande band är hård diff även
|
|
13
|
-
* vid samma poäng.
|
|
15
|
+
* vid samma poäng. Angränsning avgörs av skalans ordning i lib.mjs ANKARE,
|
|
16
|
+
* inte av ankarnumret (A17 ligger mellan A12 och A13). LIKA_TOLERANS hör till
|
|
17
|
+
* LIKA-familjen (exit 0) med
|
|
14
18
|
* en deterministisk vinnare (högsta poängen, skyddar marginalen). Den
|
|
15
19
|
* skriver aldrig om en fil och räknar aldrig fram ett medelvärde.
|
|
16
20
|
*
|
|
21
|
+
* Stegdiff (beslut D 2026-09-02, ADR-0107 amends 0078): två körningar som
|
|
22
|
+
* läser SAMMA inre ankare i bracket.steps åt motsatt håll (storre ↔ mindre)
|
|
23
|
+
* är hård diff, aldrig toleransrad, även när poängen ligger inom 0,5. Golv-
|
|
24
|
+
* och takankare i någon av körningarna undantas: när banden angränsar
|
|
25
|
+
* (A11/A12 mot A12/A13) flippar det delade gränsankaret av konstruktion, och
|
|
26
|
+
* det fallet äger redan angränsningstoleransen ovan. Utan undantaget hade
|
|
27
|
+
* varje angränsande par blivit OLIKA och toleransen upphävts i praktiken.
|
|
28
|
+
* Ankare som bara finns i ena körningen, saknat eller trasigt steps (äldre
|
|
29
|
+
* arkiv) ger ingen rad.
|
|
30
|
+
*
|
|
17
31
|
* Skriver LIKA, LIKA_TOLERANS (exit 0) eller OLIKA + diffrader (exit 1) på
|
|
18
32
|
* stdout; varningar går på stderr. Vid OLIKA väljer dispatchern ALDRIG en
|
|
19
33
|
* körning och räknar ALDRIG medelvärde — posten eskaleras till granskaren med
|
|
@@ -25,10 +39,10 @@
|
|
|
25
39
|
*
|
|
26
40
|
* Användning: node compare-scores.mjs <körning1.json> <körning2.json>
|
|
27
41
|
*/
|
|
28
|
-
import { loadRunPair, refDriftWarning, reportDiffs } from './lib.mjs'
|
|
42
|
+
import { ankarPosition, loadRunPair, norm, refDriftWarning, reportDiffs } from './lib.mjs'
|
|
29
43
|
|
|
30
44
|
// Jämförelsefälten speglar poängskalans kontrakt — varna (stderr) vid katalogdrift.
|
|
31
|
-
const REF_SHA = '
|
|
45
|
+
const REF_SHA = '810a71f2767173bdc1747e932f466f3fc25cb412b84f61fab0ca0894ff80438a'
|
|
32
46
|
{
|
|
33
47
|
const w = refDriftWarning('request-score/references/poangskala.md', REF_SHA)
|
|
34
48
|
if (w) console.error(`VARNING: ${w}`)
|
|
@@ -50,16 +64,19 @@ if (a.outcome !== b.outcome) diffs.push(`outcome: ${show(a.outcome)} ↔ ${show(
|
|
|
50
64
|
// fortfarande aldrig.
|
|
51
65
|
const TOLERANS = 0.5
|
|
52
66
|
|
|
53
|
-
/**
|
|
54
|
-
* Ankarnummer på skalan A1..A17. Golv null (under-A1-fallet) läses som 0, alltså
|
|
55
|
-
* steget under A1. Ett ankare som inte går att tolka ger null och behandlas som
|
|
56
|
-
* icke-angränsande, så en oläslig inringning aldrig kan tystas som brus.
|
|
57
|
-
*/
|
|
67
|
+
/** Ankarnummer, ENBART för deterministisk utskriftsordning av stegrader. Aldrig ett skalläge, se position(). */
|
|
58
68
|
const ankarNr = (v) => {
|
|
59
69
|
if (v === null || v === undefined) return 0
|
|
60
70
|
const m = /^A(\d+)$/.exec(String(v))
|
|
61
71
|
return m ? Number(m[1]) : null
|
|
62
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Position i skalans ordning ur lib.mjs ANKARE. Golv null (under-A1-fallet)
|
|
75
|
+
* läses som steget under A1. Ett okänt ankare ger null och behandlas som
|
|
76
|
+
* icke-angränsande, så en oläslig inringning aldrig kan tystas som brus.
|
|
77
|
+
* Ankarnumret är INTE en position: A17 ligger mellan A12 och A13 (#506).
|
|
78
|
+
*/
|
|
79
|
+
const position = (v) => (v === null || v === undefined ? -1 : ankarPosition(v))
|
|
63
80
|
/**
|
|
64
81
|
* Inringningarna får toleransbehandling bara när de faktiskt pekar på samma del
|
|
65
82
|
* av skalan: överlappande eller angränsande band. Två körningar som landar på
|
|
@@ -68,11 +85,29 @@ const ankarNr = (v) => {
|
|
|
68
85
|
* densamma.
|
|
69
86
|
*/
|
|
70
87
|
const nastanSammaBand = (fa, ta, fb, tb) => {
|
|
71
|
-
const [l1, h1, l2, h2] = [
|
|
88
|
+
const [l1, h1, l2, h2] = [position(fa), position(ta), position(fb), position(tb)]
|
|
72
89
|
if ([l1, h1, l2, h2].some((n) => n === null)) return false
|
|
73
90
|
return l1 <= h2 + 1 && l2 <= h1 + 1
|
|
74
91
|
}
|
|
75
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Ankare → relation ur bracket.steps. Saknat, icke-array eller element utan
|
|
95
|
+
* sträng-anchor/relation läses som tomt: äldre arkiv har andra stegformer och
|
|
96
|
+
* ska fortfarande gå att jämföra på poäng och band. Dubblett av samma ankare
|
|
97
|
+
* inom en körning: sista vinner.
|
|
98
|
+
*/
|
|
99
|
+
const stegRelationer = (bracket) => {
|
|
100
|
+
const out = new Map()
|
|
101
|
+
const steps = bracket?.steps
|
|
102
|
+
if (!Array.isArray(steps)) return out
|
|
103
|
+
for (const s of steps) {
|
|
104
|
+
if (!s || typeof s !== 'object') continue
|
|
105
|
+
if (typeof s.anchor !== 'string' || typeof s.relation !== 'string') continue
|
|
106
|
+
out.set(s.anchor, s.relation)
|
|
107
|
+
}
|
|
108
|
+
return out
|
|
109
|
+
}
|
|
110
|
+
|
|
76
111
|
const tolerans = []
|
|
77
112
|
// Konfidensordet (hog/medel/lag) är en självrapporterad etikett. Den visas
|
|
78
113
|
// alltid i utskriften, men fäller aldrig ensam ett par vars poäng ligger inom
|
|
@@ -94,13 +129,84 @@ if (a.outcome === 'POANG' && b.outcome === 'POANG') {
|
|
|
94
129
|
if (fa !== fb) ankarDiffs.push(`bracket.floor.anchor: ${show(fa)} ↔ ${show(fb)}`)
|
|
95
130
|
if (ta !== tb) ankarDiffs.push(`bracket.ceiling.anchor: ${show(ta)} ↔ ${show(tb)}`)
|
|
96
131
|
;(inomTolerans && nastanSammaBand(fa, ta, fb, tb) ? tolerans : diffs).push(...ankarDiffs)
|
|
132
|
+
// Stegdiff (beslut D, ADR-0107): samma inre ankare läst åt motsatt håll är
|
|
133
|
+
// oenighet om skalan, inte prisbrus, så raden går alltid till diffs. Golv och
|
|
134
|
+
// tak i någon av körningarna hoppas över: vid angränsande band flippar det
|
|
135
|
+
// delade gränsankaret av konstruktion (posten är större än A12 som golv,
|
|
136
|
+
// mindre än A12 som tak), och det fallet syns redan som floor/ceiling-rad
|
|
137
|
+
// under angränsningstoleransen (ADR-0078). Stigande ankarordning gör
|
|
138
|
+
// utskriften deterministisk oavsett i vilken ordning agenten skrev stegen.
|
|
139
|
+
const granser = new Set([fa, fb, ta, tb])
|
|
140
|
+
const stegA = stegRelationer(a.bracket)
|
|
141
|
+
const stegB = stegRelationer(b.bracket)
|
|
142
|
+
const flippar = [...stegA.keys()]
|
|
143
|
+
.filter((k) => stegB.has(k) && !granser.has(k) && stegA.get(k) !== stegB.get(k))
|
|
144
|
+
.sort((x, y) => (ankarNr(x) ?? Infinity) - (ankarNr(y) ?? Infinity) || x.localeCompare(y))
|
|
145
|
+
for (const k of flippar)
|
|
146
|
+
diffs.push(`bracket.steps[${k}].relation: ${show(stegA.get(k))} ↔ ${show(stegB.get(k))}`)
|
|
147
|
+
// Kundnära fält utanför huvudpoängen (#509). type är en självrapporterad
|
|
148
|
+
// etikett som loggas för mätning: toleransrad, fäller aldrig ensam.
|
|
149
|
+
// conditions är kundens kontraktsvillkor: ett villkor som finns i ena
|
|
150
|
+
// körningen men inte i den andra är oenighet, inte brus. alternatives
|
|
151
|
+
// matchas per normaliserad label och diffas under samma toleransregel som
|
|
152
|
+
// huvudpoängen; ett alternativ som bara finns i ena körningen visas som
|
|
153
|
+
// toleransrad så granskaren ser det utan att ett par som är ense om priset
|
|
154
|
+
// faller.
|
|
155
|
+
if ((a.type ?? null) !== (b.type ?? null))
|
|
156
|
+
tolerans.push(`type: ${show(a.type)} ↔ ${show(b.type)}`)
|
|
157
|
+
const antal = (v) => (Array.isArray(v) ? v.length : 0)
|
|
158
|
+
if (antal(a.conditions) !== antal(b.conditions))
|
|
159
|
+
diffs.push(`conditions.length: ${antal(a.conditions)} ↔ ${antal(b.conditions)}`)
|
|
160
|
+
const altNyckel = (x) => norm(x.label).toLowerCase()
|
|
161
|
+
const alternativ = (doc) =>
|
|
162
|
+
new Map(
|
|
163
|
+
(Array.isArray(doc.alternatives) ? doc.alternatives : [])
|
|
164
|
+
.filter((x) => x && typeof x === 'object' && typeof x.label === 'string')
|
|
165
|
+
.map((x) => [altNyckel(x), x]),
|
|
166
|
+
)
|
|
167
|
+
const altA = alternativ(a)
|
|
168
|
+
const altB = alternativ(b)
|
|
169
|
+
for (const [key, x] of altA) {
|
|
170
|
+
const y = altB.get(key)
|
|
171
|
+
const label = norm(x.label)
|
|
172
|
+
if (!y) {
|
|
173
|
+
tolerans.push(`alternatives[${JSON.stringify(label)}]: bara i körning 1`)
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
if (x.score === y.score) continue
|
|
177
|
+
const d = Math.abs(x.score - y.score)
|
|
178
|
+
const rad = `alternatives[${JSON.stringify(label)}].score: ${show(x.score)} ↔ ${show(y.score)}`
|
|
179
|
+
;(Number.isFinite(d) && d <= TOLERANS ? tolerans : diffs).push(rad)
|
|
180
|
+
}
|
|
181
|
+
for (const [key, y] of altB)
|
|
182
|
+
if (!altA.has(key))
|
|
183
|
+
tolerans.push(`alternatives[${JSON.stringify(norm(y.label))}]: bara i körning 2`)
|
|
184
|
+
} else if (a.outcome === 'OVER_SKALAN' && b.outcome === 'OVER_SKALAN') {
|
|
185
|
+
// ADR-0116: inget tak är prisbärande. Utfallet är lika, golvet avgör.
|
|
186
|
+
const fa = a.bracket?.floor?.anchor ?? null
|
|
187
|
+
const fb = b.bracket?.floor?.anchor ?? null
|
|
188
|
+
if (fa !== fb) diffs.push(`bracket.floor.anchor: ${show(fa)} ↔ ${show(fb)}`)
|
|
189
|
+
if (konfDiff) tolerans.push(konfDiff)
|
|
97
190
|
} else if (konfDiff) {
|
|
98
191
|
diffs.push(konfDiff)
|
|
99
192
|
}
|
|
100
193
|
|
|
194
|
+
// Täckningspost (ADR-0119): fältets närvaro är oenighet om vad som ska
|
|
195
|
+
// prissättas, innehållet jämförs inte.
|
|
196
|
+
const harCp = (d) => d.coverage_post !== undefined && d.coverage_post !== null
|
|
197
|
+
if (harCp(a) !== harCp(b))
|
|
198
|
+
diffs.push(`coverage_post: ${harCp(a) ? 'finns' : 'saknas'} ↔ ${harCp(b) ? 'finns' : 'saknas'}`)
|
|
199
|
+
|
|
101
200
|
if (diffs.length === 0 && tolerans.length > 0) {
|
|
102
|
-
|
|
103
|
-
|
|
201
|
+
// OVER_SKALAN bär score: null (ADR-0116), "högsta poängen" är obegripligt
|
|
202
|
+
// för ett par utan tal. Golvet redan lika (annars hade grenen ovan diffat),
|
|
203
|
+
// så bara konfidensen skiljer; körning 1 vinner deterministiskt.
|
|
204
|
+
if (a.outcome === 'OVER_SKALAN' && b.outcome === 'OVER_SKALAN') {
|
|
205
|
+
console.log('LIKA_TOLERANS: samma golv, konfidens skiljer (körning 1)')
|
|
206
|
+
} else {
|
|
207
|
+
const winner = b.score > a.score ? 2 : 1
|
|
208
|
+
console.log(`LIKA_TOLERANS: högsta gäller ${Math.max(a.score, b.score)} (körning ${winner})`)
|
|
209
|
+
}
|
|
104
210
|
for (const t of tolerans) console.log(`- ${t}`)
|
|
105
211
|
process.exit(0)
|
|
106
212
|
}
|
|
@@ -6,17 +6,59 @@
|
|
|
6
6
|
* ska fortsätta gå att kontrollera. Fångar det starka modeller ändå slarvar
|
|
7
7
|
* med i lång kontext:
|
|
8
8
|
* parafraserade "ordagranna" citat, läckta protokolltermer i kundtext, brutna
|
|
9
|
-
* enum- och upplösningsregler.
|
|
10
|
-
*
|
|
9
|
+
* enum- och upplösningsregler. Ett svartlistat ord som står i specen (kundens
|
|
10
|
+
* eget dokument; analysen är Haus-intern och friar inget) är kundvokabulär
|
|
11
|
+
* och fälls inte (#504). Bedömer aldrig innehållets rimlighet
|
|
12
|
+
* — bara kontraktet. Vid BROTT: dispatcha agenten om EN gång med felraderna, sedan
|
|
11
13
|
* stopp. VARNING är heuristik (t.ex. ankar-id som krockar med pappersformat) —
|
|
12
14
|
* visas, stoppar inte.
|
|
13
15
|
*
|
|
16
|
+
* Stegschemat: `bracket.steps` (och `alternatives[i].bracket.steps`) är en
|
|
17
|
+
* lista av objekt med exakt nycklarna `anchor` (A<n>), `relation`
|
|
18
|
+
* (storre/mindre/lika) och `grund` (icke-tom). Egna nycklar är brott, som i
|
|
19
|
+
* kuvertet. Äldre arkiv med strängsteg läses fortfarande av report.mjs;
|
|
20
|
+
* bara färska körningar går genom den här kontrollen.
|
|
21
|
+
*
|
|
22
|
+
* Inringningens konsistens (ADR-0115): golv före tak, between två angränsande
|
|
23
|
+
* ankare inuti [golv, tak] (får sammanfalla med dem), poängen mellan
|
|
24
|
+
* between-paret, och varje stegs relation mot poängen. Poäng PÅ ett ankare
|
|
25
|
+
* trots storre/mindre är VARNING bara när between-paret inte rymmer någon
|
|
26
|
+
* poäng emellan; annars BROTT. Ordningen kommer från lib.mjs ANKARE, aldrig
|
|
27
|
+
* ur ankarnumret.
|
|
28
|
+
*
|
|
29
|
+
* Motsägelsegrinden (ADR-0106): när impact.md ges och analysens rad
|
|
30
|
+
* `**Avvikelse mot specen:**` bär etiketten `motsägelse` är TILLBAKA_TILL_SPEC
|
|
31
|
+
* det enda giltiga utfallet. Varje annat outcome är brott. Saknad etikett
|
|
32
|
+
* fäller inget här, precheck äger etikettkravet.
|
|
33
|
+
*
|
|
34
|
+
* Språkgrinden (#521): med `--sprak <svenska|engelska>` prövas varje
|
|
35
|
+
* kundtextfält mot språket. Två OLIKA funktionsord ur fel språk (hela ord,
|
|
36
|
+
* skiftlägesokänsligt, listorna i lib.mjs FUNKTIONSORD) är BROTT; diakritiska
|
|
37
|
+
* tecken är ingen signal. Utan flaggan görs ingen språkkontroll.
|
|
38
|
+
*
|
|
14
39
|
* Användning: node contract-check.mjs <run.json> <spec.md> [impact.md]
|
|
40
|
+
* [--expect-id <id>] [--state <state.json>] [--sprak <svenska|engelska>]
|
|
15
41
|
* Exit 0 = ok, 1 = kontraktsbrott (listade), 2 = användningsfel.
|
|
16
42
|
*/
|
|
17
43
|
import { readFileSync } from 'node:fs'
|
|
18
44
|
|
|
19
|
-
import {
|
|
45
|
+
import {
|
|
46
|
+
refDriftWarning,
|
|
47
|
+
norm,
|
|
48
|
+
quoteOk,
|
|
49
|
+
avvikelseEtikett,
|
|
50
|
+
CLASS_UTFALL,
|
|
51
|
+
SCORE_OUTCOMES,
|
|
52
|
+
CUSTOMER_BLACKLIST,
|
|
53
|
+
MODES,
|
|
54
|
+
STEP_RELATIONS,
|
|
55
|
+
KONFIDENS,
|
|
56
|
+
ankarPosition,
|
|
57
|
+
ankarPoang,
|
|
58
|
+
toppAnkare,
|
|
59
|
+
TYPER,
|
|
60
|
+
felSprakOrd,
|
|
61
|
+
} from './lib.mjs'
|
|
20
62
|
|
|
21
63
|
const argv = process.argv.slice(2)
|
|
22
64
|
let expectId = null
|
|
@@ -38,6 +80,39 @@ if (idFlag !== -1) {
|
|
|
38
80
|
expectId = v
|
|
39
81
|
argv.splice(idFlag, 2)
|
|
40
82
|
}
|
|
83
|
+
// --state pekar ut den buntdels/split-statefil (#503) som avgör om posten är
|
|
84
|
+
// en split-del (split.part) och därmed får sätta OVER_SKALAN. Utan värde är
|
|
85
|
+
// det, som --expect-id ovan, nästan säkert ett dispatcherfel: stanna hårt.
|
|
86
|
+
let statePath = null
|
|
87
|
+
const stateFlag = argv.indexOf('--state')
|
|
88
|
+
if (stateFlag !== -1) {
|
|
89
|
+
const v = argv[stateFlag + 1]
|
|
90
|
+
if (v === undefined || v.startsWith('--')) {
|
|
91
|
+
console.error('--state kräver ett värde')
|
|
92
|
+
process.exit(2)
|
|
93
|
+
}
|
|
94
|
+
statePath = v
|
|
95
|
+
argv.splice(stateFlag, 2)
|
|
96
|
+
}
|
|
97
|
+
// --sprak (#521) ger kundtextens språk: varje kundtextfält prövas mot det med
|
|
98
|
+
// funktionsordsheuristiken i lib.mjs (felSprakOrd). Utan flaggan görs ingen
|
|
99
|
+
// språkkontroll. Ett saknat eller okänt värde är, som ovan, ett dispatcherfel
|
|
100
|
+
// som stannar hårt: grinden gissar aldrig språk (ADR-0114).
|
|
101
|
+
let sprak = null
|
|
102
|
+
const sprakFlag = argv.indexOf('--sprak')
|
|
103
|
+
if (sprakFlag !== -1) {
|
|
104
|
+
const v = argv[sprakFlag + 1]
|
|
105
|
+
if (v === undefined || v.startsWith('--')) {
|
|
106
|
+
console.error('--sprak kräver ett värde (svenska eller engelska)')
|
|
107
|
+
process.exit(2)
|
|
108
|
+
}
|
|
109
|
+
if (v !== 'svenska' && v !== 'engelska') {
|
|
110
|
+
console.error(`--sprak: okänt språk "${v}" (giltiga: svenska, engelska)`)
|
|
111
|
+
process.exit(2)
|
|
112
|
+
}
|
|
113
|
+
sprak = v
|
|
114
|
+
argv.splice(sprakFlag, 2)
|
|
115
|
+
}
|
|
41
116
|
// En felstavad flaggform (t.ex. `--expectid`, bindestrecket i "expect-id"
|
|
42
117
|
// tappat) matchar aldrig `--expect-id` ovan och blir kvar i `argv` som ett
|
|
43
118
|
// obehandlat argument. Utan den här kontrollen landade den tyst som
|
|
@@ -48,17 +123,33 @@ if (idFlag !== -1) {
|
|
|
48
123
|
// användningsfel, aldrig en gissning om vad anroparen menade.
|
|
49
124
|
const okandFlagga = argv.find((a) => a.startsWith('--'))
|
|
50
125
|
if (okandFlagga !== undefined) {
|
|
51
|
-
console.error(
|
|
126
|
+
console.error(
|
|
127
|
+
`okänd flagga: ${okandFlagga} (giltiga: --expect-id <id>, --state <state.json>, --sprak <svenska|engelska>)`,
|
|
128
|
+
)
|
|
52
129
|
process.exit(2)
|
|
53
130
|
}
|
|
54
131
|
const [jsonPath, specPath, impactPath] = argv
|
|
55
132
|
if (!jsonPath || !specPath) {
|
|
56
133
|
console.error(
|
|
57
|
-
'användning: node contract-check.mjs <run.json> <spec.md> [impact.md] [--expect-id <id>]',
|
|
134
|
+
'användning: node contract-check.mjs <run.json> <spec.md> [impact.md] [--expect-id <id>] [--state <state.json>] [--sprak <svenska|engelska>]',
|
|
58
135
|
)
|
|
59
136
|
process.exit(2)
|
|
60
137
|
}
|
|
61
138
|
|
|
139
|
+
// Split-status (#503): null när --state inte gavs (OVER_SKALAN blir då en
|
|
140
|
+
// VARNING, inte ett BROTT), annars true/false ur split.part i statefilen. En
|
|
141
|
+
// oläsbar statefil är ett dispatcherfel, inte agentens brott.
|
|
142
|
+
let splitDel = null
|
|
143
|
+
if (statePath !== null) {
|
|
144
|
+
try {
|
|
145
|
+
const st = JSON.parse(readFileSync(statePath, 'utf8'))
|
|
146
|
+
splitDel = st?.split?.part != null
|
|
147
|
+
} catch (e) {
|
|
148
|
+
console.error(`kunde inte läsa ${statePath}: ${e.message}`)
|
|
149
|
+
process.exit(2)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
62
153
|
const problems = []
|
|
63
154
|
const warnings = []
|
|
64
155
|
|
|
@@ -71,7 +162,7 @@ const warnings = []
|
|
|
71
162
|
// referensfil att drifta mot.
|
|
72
163
|
const EXPECTED_REFS = {
|
|
73
164
|
'request-score/references/poangskala.md':
|
|
74
|
-
'
|
|
165
|
+
'810a71f2767173bdc1747e932f466f3fc25cb412b84f61fab0ca0894ff80438a',
|
|
75
166
|
}
|
|
76
167
|
for (const [rel, expected] of Object.entries(EXPECTED_REFS)) {
|
|
77
168
|
const w = refDriftWarning(rel, expected)
|
|
@@ -87,10 +178,16 @@ try {
|
|
|
87
178
|
// Oläsbart underlag är dispatcherns fel (exit 2), aldrig ett kontraktsbrott —
|
|
88
179
|
// exit 1 skulle trigga en meningslös och dyr omdispatch av agenten.
|
|
89
180
|
let source
|
|
181
|
+
// Analysens råtext hålls isär från det normaliserade underlaget: etiketten på
|
|
182
|
+
// avvikelseraden läses ur den (motsägelsegrinden nedan), och norm() skulle
|
|
183
|
+
// kunna vika radbrytningar som rad-regexen i avvikelseEtikett förlitar sig på.
|
|
184
|
+
let impactText = null
|
|
185
|
+
let kundUnderlag
|
|
90
186
|
try {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
)
|
|
187
|
+
const specText = readFileSync(specPath, 'utf8')
|
|
188
|
+
if (impactPath) impactText = readFileSync(impactPath, 'utf8')
|
|
189
|
+
source = norm(specText + '\n' + (impactText ?? ''))
|
|
190
|
+
kundUnderlag = norm(specText)
|
|
94
191
|
} catch (e) {
|
|
95
192
|
console.error(`kunde inte läsa underlaget: ${e.message}`)
|
|
96
193
|
process.exit(2)
|
|
@@ -108,31 +205,8 @@ const collectStrings = (v) => {
|
|
|
108
205
|
return []
|
|
109
206
|
}
|
|
110
207
|
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
// står medvetet INTE här. Poäng och skalans mekanik är interna. Gränserna är
|
|
114
|
-
// Unicode-medvetna (JS \b bryter vid å/ä/ö), och "protokoll" är snävat så att
|
|
115
|
-
// vardagssvenska som "protokollförs" går fri medan klassningsprotokollet fångas.
|
|
116
|
-
const B = (pat) => new RegExp(`(?<![\\p{L}\\d_])(?:${pat})`, 'iu')
|
|
117
|
-
const CUSTOMER_BLACKLIST = [
|
|
118
|
-
B('poäng\\p{L}*'),
|
|
119
|
-
B('basklass\\p{L}*'),
|
|
120
|
-
B('bump\\p{L}*'),
|
|
121
|
-
B('ankar\\p{L}*'),
|
|
122
|
-
B('referensuppdrag\\p{L}*'),
|
|
123
|
-
B('inringning\\p{L}*'),
|
|
124
|
-
B('konfidens\\p{L}*'),
|
|
125
|
-
// Substantivformer av skalan är mekanik; verbet "skalar" (skalbarhet) är
|
|
126
|
-
// legitim kundsvenska och går fritt.
|
|
127
|
-
B('skal(?:a|an|ans|or|orna)(?!\\p{L})'),
|
|
128
|
-
B('protokoll(?:et|en|s)?(?!\\p{L})'),
|
|
129
|
-
/klassningsprotokoll/iu,
|
|
130
|
-
B('blast\\s*radius'),
|
|
131
|
-
B('touchpoint\\p{L}*'),
|
|
132
|
-
// u-flaggan är obligatorisk: utan den är \p{L} i lookaround teckenklassen
|
|
133
|
-
// "p{L" och "era B2B-kunder" blir ett falskt BROTT.
|
|
134
|
-
/(?<![\p{L}\d_])[SB][1-3](?![\p{L}\d_])/u,
|
|
135
|
-
]
|
|
208
|
+
// CUSTOMER_BLACKLIST bor i lib.mjs (delad med kontroll.mjs, som avgör om ett
|
|
209
|
+
// brott är ett rent kundtextbrott innan en fältomskrivning kan täcka det).
|
|
136
210
|
// Ankar-id (A1–A17) kolliderar strukturellt med pappersformat (A3/A4/A5) och
|
|
137
211
|
// går inte att regexa isär — heuristik, alltså varning i stället för brott.
|
|
138
212
|
const ANCHOR_RE = /(?<![\p{L}\d_])A(?:1[0-7]|[1-9])(?![\p{L}\d_])/u
|
|
@@ -168,18 +242,36 @@ const ANCHOR_RE = /(?<![\p{L}\d_])A(?:1[0-7]|[1-9])(?![\p{L}\d_])/u
|
|
|
168
242
|
const IDIOM_ALLOW = [/hela\s+poängen\s+(?:med|i)\b(?![^.!?]*\d)/giu, /poängen\s+är\s+att\b/giu]
|
|
169
243
|
const maskIdioms = (text) =>
|
|
170
244
|
IDIOM_ALLOW.reduce((t, re) => t.replace(re, (m) => '�'.repeat(m.length)), text)
|
|
245
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
246
|
+
/**
|
|
247
|
+
* Ett svartlistat ord som redan förekommer som HELT ord i kundens underlag
|
|
248
|
+
* (specen) är kundens vokabulär, inte skalans mekanik, och fälls inte (#504).
|
|
249
|
+
* Analysen (impact.md) är Haus-intern och friar inget: den skrivs av en
|
|
250
|
+
* Haus-agent, inte kunden, så ett protokollord där är inget kundläckage.
|
|
251
|
+
* Kundens sajt hade en blocktyp "anchor" som agenten återgav som
|
|
252
|
+
* "ankartrigger" — ett läckage kan inte komma från kundens egna dokument.
|
|
253
|
+
* Helordsgräns: "ankartrigger" i specen friar inte "ankartriggern".
|
|
254
|
+
*/
|
|
255
|
+
const iUnderlaget = (ord) =>
|
|
256
|
+
new RegExp(`(?<![\\p{L}\\d_])${escapeRe(ord)}(?![\\p{L}\\d_])`, 'iu').test(kundUnderlag)
|
|
171
257
|
const checkCustomerText = (text, field) => {
|
|
172
258
|
if (typeof text !== 'string') return
|
|
173
259
|
const masked = maskIdioms(text)
|
|
174
260
|
for (const re of CUSTOMER_BLACKLIST) {
|
|
175
261
|
const m = masked.match(re)
|
|
176
|
-
if (m) problems.push(`${field}: protokollterm "${m[0]}" i kundtext`)
|
|
262
|
+
if (m && !iUnderlaget(m[0])) problems.push(`${field}: protokollterm "${m[0]}" i kundtext`)
|
|
177
263
|
}
|
|
178
264
|
// Ankarvarningen körs mot ORIGINALET, aldrig mot den maskade strängen.
|
|
179
265
|
// Maskningen bevarar längden men inte innehållet, och varningscitatet ska
|
|
180
266
|
// visa vad som faktiskt stod i texten.
|
|
181
267
|
const a = text.match(ANCHOR_RE)
|
|
182
268
|
if (a) warnings.push(`${field}: "${a[0]}" — ankar-id eller pappersformat? Kontrollera`)
|
|
269
|
+
// Språkgrinden (#521), också mot originalet: två OLIKA funktionsord ur fel
|
|
270
|
+
// språk fäller, ett enda är ett lån. Raden har med flit en annan form än
|
|
271
|
+
// protokollterm-brottet: kundtextfixen i kontroll.mjs byter ord, den
|
|
272
|
+
// översätter inte, så brottet ska till omdispatch.
|
|
273
|
+
if (sprak !== null && felSprakOrd(text, sprak).length >= 2)
|
|
274
|
+
problems.push(`${field}: kundtext på fel språk (väntat ${sprak})`)
|
|
183
275
|
}
|
|
184
276
|
|
|
185
277
|
/** Poängupplösning: 0.25-steg under 3, heltal från 3, alltid > 0.
|
|
@@ -193,7 +285,14 @@ const checkScoreValue = (score, field) => {
|
|
|
193
285
|
else if (score < 3 ? Math.round(score * 4) !== score * 4 : !Number.isInteger(score))
|
|
194
286
|
problems.push(`${field}: ${score} bryter upplösningen (0.25-steg under 3, heltal från 3)`)
|
|
195
287
|
}
|
|
196
|
-
|
|
288
|
+
/** Nästa giltiga poäng över `lo` på skalans upplösning (0,25 under 3, heltal från 3). */
|
|
289
|
+
const nastaSteg = (lo) => (lo < 3 ? lo + 0.25 : lo + 1)
|
|
290
|
+
/** Avrundningskollaps: between-paret rymmer ingen giltig poäng strikt emellan sig
|
|
291
|
+
* (A13/A14 = 12/13, A9/A10 = 4/4). Då landar avrundningen av nödvändighet PÅ
|
|
292
|
+
* ett av ankarna även när posten bedömts större/mindre än det (#502). */
|
|
293
|
+
const kollapsar = (lo, hi) => !(nastaSteg(lo) < hi)
|
|
294
|
+
|
|
295
|
+
const checkBracket = (b, field, score) => {
|
|
197
296
|
// Under-A1-fallet är kontraktsenligt golvlöst: "Får du inget golv: posten är
|
|
198
297
|
// mindre än A1 och får A1:s poäng" — då är floor null och taket A1.
|
|
199
298
|
const floorless = b?.floor == null && b?.ceiling?.anchor === 'A1'
|
|
@@ -214,10 +313,118 @@ const checkBracket = (b, field) => {
|
|
|
214
313
|
)
|
|
215
314
|
if (b.steps.length === 0)
|
|
216
315
|
problems.push(`${field}.steps: tom — inringningen ska redovisa ankarjämförelserna`)
|
|
316
|
+
|
|
317
|
+
// Konsistens (ADR-0115, #502/#496): delarna ska hänga ihop, inte bara finnas.
|
|
318
|
+
// Bara när poängen är ett tal — annars har checkScoreValue redan fällt.
|
|
319
|
+
const harPoang = typeof score === 'number'
|
|
320
|
+
let kollaps = false
|
|
321
|
+
if (floorless) {
|
|
322
|
+
// Kontraktet självt kollapsar hit: posten är MINDRE än A1 men får A1:s
|
|
323
|
+
// poäng, så "mindre än A1" med A1:s poäng är varning. "storre mot A1" är
|
|
324
|
+
// däremot en rak motsägelse av kontraktet (#514 p2), och between prövas
|
|
325
|
+
// mot ankartabellen precis som i den golvade grenen.
|
|
326
|
+
const a1 = ankarPoang('A1')
|
|
327
|
+
if (harPoang && score !== a1)
|
|
328
|
+
problems.push(
|
|
329
|
+
`${field}: golvlös inringning (under A1) kräver A1:s poäng ${a1}, fick ${score}`,
|
|
330
|
+
)
|
|
331
|
+
for (const id of b.between.filter((x) => ankarPosition(x) === null))
|
|
332
|
+
problems.push(`${field}: okänt ankare "${id}" (skalan är A1–A17)`)
|
|
333
|
+
for (const [i, step] of b.steps.entries())
|
|
334
|
+
if (step && step.anchor === 'A1' && step.relation === 'storre')
|
|
335
|
+
problems.push(
|
|
336
|
+
`${field}.steps[${i}]: storre mot A1 i golvlös inringning, posten är per kontrakt mindre än A1`,
|
|
337
|
+
)
|
|
338
|
+
kollaps = true
|
|
339
|
+
} else if (b.between.length === 2) {
|
|
340
|
+
const okanda = [b.floor.anchor, b.ceiling.anchor, ...b.between].filter(
|
|
341
|
+
(id) => ankarPosition(id) === null,
|
|
342
|
+
)
|
|
343
|
+
if (okanda.length > 0) {
|
|
344
|
+
for (const id of okanda) problems.push(`${field}: okänt ankare "${id}" (skalan är A1–A17)`)
|
|
345
|
+
} else {
|
|
346
|
+
const [pf, pc] = [ankarPosition(b.floor.anchor), ankarPosition(b.ceiling.anchor)]
|
|
347
|
+
const [b0, b1] = b.between
|
|
348
|
+
const [p0, p1] = [ankarPosition(b0), ankarPosition(b1)]
|
|
349
|
+
if (pf >= pc)
|
|
350
|
+
problems.push(
|
|
351
|
+
`${field}: golv ${b.floor.anchor} ligger inte före tak ${b.ceiling.anchor} i skalans ordning`,
|
|
352
|
+
)
|
|
353
|
+
if (p1 !== p0 + 1)
|
|
354
|
+
problems.push(
|
|
355
|
+
`${field}.between: ${b0}/${b1} är inte angränsande i skalans ordning (A17 ligger mellan A12 och A13)`,
|
|
356
|
+
)
|
|
357
|
+
else {
|
|
358
|
+
if (p0 < pf || p1 > pc)
|
|
359
|
+
problems.push(
|
|
360
|
+
`${field}.between: ${b0}/${b1} ligger utanför golv ${b.floor.anchor}–tak ${b.ceiling.anchor}`,
|
|
361
|
+
)
|
|
362
|
+
// Bara ett bekräftat angränsande par har ett lo/hi (#514 p3): ett
|
|
363
|
+
// reverserat par gav lo > hi, en vilseledande rad och en falsk kollaps.
|
|
364
|
+
const [lo, hi] = [ankarPoang(b0), ankarPoang(b1)]
|
|
365
|
+
if (harPoang && (score < lo || score > hi))
|
|
366
|
+
problems.push(
|
|
367
|
+
`${field === 'bracket' ? 'score' : field.replace(/\.bracket$/, '.score')}: ${score} ligger utanför between ${b0} (${lo})–${b1} (${hi})`,
|
|
368
|
+
)
|
|
369
|
+
kollaps = kollapsar(lo, hi)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
for (const [i, step] of b.steps.entries())
|
|
374
|
+
checkStep(step, `${field}.steps[${i}]`, harPoang ? score : null, kollaps)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Ett steg i inringningen: exakt nycklarna anchor/relation/grund, inga egna.
|
|
378
|
+
* Okänd nyckel fälls för sig så omdispatchen ser vad som ska bort, och de
|
|
379
|
+
* tre obligatoriska kontrolleras oberoende av varandra. Med `score` prövas
|
|
380
|
+
* dessutom att relationen stämmer med poängen (ADR-0115). */
|
|
381
|
+
const checkStep = (step, field, score, kollaps) => {
|
|
382
|
+
if (step === null || typeof step !== 'object' || Array.isArray(step)) {
|
|
383
|
+
problems.push(`${field}: inte ett objekt (schemat är {anchor, relation, grund})`)
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
for (const key of Object.keys(step))
|
|
387
|
+
if (!STEP_KEYS.includes(key))
|
|
388
|
+
problems.push(
|
|
389
|
+
`${field}: okänd nyckel "${key}" (schemat är exakt ${STEP_KEYS.join('/')}, egna nycklar är förbjudna)`,
|
|
390
|
+
)
|
|
391
|
+
if (step.anchor === undefined) problems.push(`${field}.anchor: saknas`)
|
|
392
|
+
else if (typeof step.anchor !== 'string' || !ANCHOR_ID_RE.test(step.anchor))
|
|
393
|
+
problems.push(`${field}.anchor: "${step.anchor}" är inte ett ankar-id (A<n>)`)
|
|
394
|
+
else if (ankarPoang(step.anchor) === null)
|
|
395
|
+
problems.push(`${field}.anchor: okänt ankare "${step.anchor}" (skalan är A1–A17)`)
|
|
396
|
+
if (step.relation === undefined) problems.push(`${field}.relation: saknas`)
|
|
397
|
+
else if (!STEP_RELATIONS.includes(step.relation))
|
|
398
|
+
problems.push(
|
|
399
|
+
`${field}.relation: ogiltigt värde "${step.relation}" (giltiga: ${STEP_RELATIONS.join(', ')})`,
|
|
400
|
+
)
|
|
401
|
+
if (typeof step.grund !== 'string' || step.grund.trim().length === 0)
|
|
402
|
+
problems.push(`${field}.grund: saknas eller tom sträng`)
|
|
403
|
+
|
|
404
|
+
// Relation mot poäng. "Landar den på samma nivå som ett referensuppdrag, ge
|
|
405
|
+
// den det ankarets poäng rakt av" (SKILL.md steg 4) — då är relationen lika.
|
|
406
|
+
const ap = ankarPoang(step.anchor)
|
|
407
|
+
if (score === null || ap === null || !STEP_RELATIONS.includes(step.relation)) return
|
|
408
|
+
const id = step.anchor
|
|
409
|
+
if (step.relation === 'lika') {
|
|
410
|
+
if (score !== ap) problems.push(`${field}: lika med ${id} (${ap}) men poängen är ${score}`)
|
|
411
|
+
} else if (step.relation === 'storre' && score < ap) {
|
|
412
|
+
problems.push(`${field}: storre mot ${id} (${ap}) men poängen ${score} är lägre`)
|
|
413
|
+
} else if (step.relation === 'mindre' && score > ap) {
|
|
414
|
+
problems.push(`${field}: mindre än ${id} (${ap}) men poängen ${score} är högre`)
|
|
415
|
+
} else if (score === ap) {
|
|
416
|
+
const rad = `${field}: ${step.relation === 'storre' ? 'storre mot' : 'mindre än'} ${id} men poängen ${score} är ${id}:s poäng`
|
|
417
|
+
if (kollaps)
|
|
418
|
+
warnings.push(`${rad} (avrundningskollaps: between-paret rymmer ingen poäng emellan)`)
|
|
419
|
+
else problems.push(`${rad} — relationen ska vara lika, eller poängen flyttas`)
|
|
420
|
+
}
|
|
217
421
|
}
|
|
218
422
|
|
|
219
|
-
const MODES = ['pricing', 'guard']
|
|
220
423
|
const BASKLASS = ['TINY', 'SMALL', 'MEDIUM', 'LARGE', 'OVER']
|
|
424
|
+
// MODES, STEP_RELATIONS och KONFIDENS bor i lib.mjs (delade med kontroll.mjs,
|
|
425
|
+
// som reparerar samma enumer innan dispatch).
|
|
426
|
+
const STEP_KEYS = ['anchor', 'relation', 'grund']
|
|
427
|
+
const ANCHOR_ID_RE = /^A\d+$/
|
|
221
428
|
|
|
222
429
|
// Kuverten är slutna: agenterna får aldrig lägga till egna nycklar ("ordagrant
|
|
223
430
|
// och orört"). Okänd toppnyckel är ett brott, inte en utökning.
|
|
@@ -237,6 +444,7 @@ const ENVELOPE_KEYS = {
|
|
|
237
444
|
'alternatives',
|
|
238
445
|
'dependencies',
|
|
239
446
|
'notes',
|
|
447
|
+
'coverage_post',
|
|
240
448
|
'customer_rationale',
|
|
241
449
|
'guard_diff',
|
|
242
450
|
],
|
|
@@ -331,11 +539,19 @@ if (doc.classification !== undefined) {
|
|
|
331
539
|
checkEnvelopeKeys('score')
|
|
332
540
|
if (!MODES.includes(doc.mode)) problems.push(`mode: ogiltigt värde "${doc.mode}"`)
|
|
333
541
|
if (!OUTCOMES.includes(doc.outcome)) problems.push(`outcome: ogiltigt värde "${doc.outcome}"`)
|
|
334
|
-
if (!
|
|
542
|
+
if (!KONFIDENS.includes(doc.confidence))
|
|
335
543
|
problems.push(`confidence: ogiltigt värde "${doc.confidence}"`)
|
|
544
|
+
// Typen (#509): loggas för att mäta systematisk felplacering per typ, så
|
|
545
|
+
// värdet måste vara ett av översiktstabellens fem. Tom typ tolereras bara på
|
|
546
|
+
// stopputfall (där finns ingen placering att mäta).
|
|
547
|
+
const typTom = doc.type === undefined || doc.type === null || doc.type === ''
|
|
548
|
+
if (doc.outcome === 'POANG' && typTom)
|
|
549
|
+
problems.push('type: saknas (POANG kräver en av översiktstabellens typer)')
|
|
550
|
+
else if (!typTom && !TYPER.includes(doc.type))
|
|
551
|
+
problems.push(`type: ogiltigt värde "${doc.type}" (giltiga: ${TYPER.join(', ')})`)
|
|
336
552
|
if (doc.outcome === 'POANG') {
|
|
337
553
|
checkScoreValue(doc.score, 'score')
|
|
338
|
-
checkBracket(doc.bracket, 'bracket')
|
|
554
|
+
checkBracket(doc.bracket, 'bracket', doc.score)
|
|
339
555
|
if (!Array.isArray(doc.drivers) || doc.drivers.length < 2 || doc.drivers.length > 3)
|
|
340
556
|
problems.push(
|
|
341
557
|
`drivers: ${Array.isArray(doc.drivers) ? doc.drivers.length : 'saknas'} (skillen kräver två eller tre faktorer)`,
|
|
@@ -343,6 +559,52 @@ if (doc.classification !== undefined) {
|
|
|
343
559
|
} else if (doc.score != null) {
|
|
344
560
|
problems.push(`score: ska vara null när outcome är ${doc.outcome}`)
|
|
345
561
|
}
|
|
562
|
+
// #503: inget tak är ett prisbärande utfall. OVER_SKALAN får bara sättas på
|
|
563
|
+
// en split-del (skillen steg 2), en buntdel är inte en split-del; och båda
|
|
564
|
+
// inget-tak-utfallen måste ha jämfört posten mot skalans översta ankare
|
|
565
|
+
// innan de ger upp.
|
|
566
|
+
if (doc.outcome === 'OVER_SKALAN') {
|
|
567
|
+
if (splitDel === null)
|
|
568
|
+
warnings.push('OVER_SKALAN: split-status okänd (kör med --state <state.json> för att grinda)')
|
|
569
|
+
else if (!splitDel)
|
|
570
|
+
problems.push(
|
|
571
|
+
'OVER_SKALAN kräver att posten är en split-del (split.part), en buntdel är inte en split-del: utfallet är DELA_UPP',
|
|
572
|
+
)
|
|
573
|
+
}
|
|
574
|
+
if (doc.outcome === 'DELA_UPP' || doc.outcome === 'OVER_SKALAN') {
|
|
575
|
+
const topp = toppAnkare()
|
|
576
|
+
const steps = Array.isArray(doc.bracket?.steps) ? doc.bracket.steps : []
|
|
577
|
+
if (!steps.some((s) => s && s.anchor === topp))
|
|
578
|
+
problems.push(
|
|
579
|
+
`inget tak kräver en jämförelse mot ${topp} i bracket.steps, annars är taket inte prövat`,
|
|
580
|
+
)
|
|
581
|
+
}
|
|
582
|
+
// Motsägelsegrinden (ADR-0106): analysen har redan sagt att specen och
|
|
583
|
+
// koden inte går ihop, då finns inget att poängsätta och inget att
|
|
584
|
+
// konceptera. Regeln har inga undantag (samma text som i skillen). Utan
|
|
585
|
+
// etikett (äldre analys) fälls inget, det kravet äger precheck.
|
|
586
|
+
if (impactText !== null && avvikelseEtikett(impactText) === 'motsägelse') {
|
|
587
|
+
if (doc.outcome !== 'TILLBAKA_TILL_SPEC')
|
|
588
|
+
problems.push(
|
|
589
|
+
`analysen bär motsägelse mot specen: ${doc.outcome} är inte giltigt, TILLBAKA_TILL_SPEC krävs`,
|
|
590
|
+
)
|
|
591
|
+
}
|
|
592
|
+
// Täckningspost (ADR-0119, #508): antagandet "satt som om tester fanns" är
|
|
593
|
+
// ett strukturerat fält, inte en fritextnot, så det kan bli en egen post.
|
|
594
|
+
if (doc.coverage_post !== undefined) {
|
|
595
|
+
const cp = doc.coverage_post
|
|
596
|
+
if (cp === null || typeof cp !== 'object' || Array.isArray(cp))
|
|
597
|
+
problems.push('coverage_post: inte ett objekt (schemat är {yta, omfattning})')
|
|
598
|
+
else {
|
|
599
|
+
for (const key of Object.keys(cp))
|
|
600
|
+
if (!['yta', 'omfattning'].includes(key))
|
|
601
|
+
problems.push(`coverage_post: okänd nyckel "${key}"`)
|
|
602
|
+
for (const key of ['yta', 'omfattning'])
|
|
603
|
+
if (typeof cp[key] !== 'string' || cp[key].trim() === '')
|
|
604
|
+
problems.push(`coverage_post.${key}: saknas eller tom sträng`)
|
|
605
|
+
if (typeof cp.yta === 'string') checkCustomerText(cp.yta, 'coverage_post.yta')
|
|
606
|
+
}
|
|
607
|
+
}
|
|
346
608
|
// Ett ankare utan sitt lösta citat betyder att dispatchern hoppat över
|
|
347
609
|
// resolve-anchors.mjs: dispatcherns fel (exit 2), aldrig agentens brott.
|
|
348
610
|
for (const [i, d] of (Array.isArray(doc.drivers) ? doc.drivers : []).entries())
|
|
@@ -374,7 +636,7 @@ if (doc.classification !== undefined) {
|
|
|
374
636
|
checkCustomerText(alt?.adds, `alternatives[${i}].adds`)
|
|
375
637
|
if (alt?.score != null) {
|
|
376
638
|
checkScoreValue(alt.score, `alternatives[${i}].score`)
|
|
377
|
-
checkBracket(alt?.bracket, `alternatives[${i}].bracket
|
|
639
|
+
checkBracket(alt?.bracket, `alternatives[${i}].bracket`, alt.score)
|
|
378
640
|
}
|
|
379
641
|
}
|
|
380
642
|
} else {
|