@haus-tech/haus-workflow 5.12.1 → 6.0.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.
@@ -3,10 +3,11 @@
3
3
  * driftvakterna, dispatcher-rälsen för compare-scripten och hashningen bor här,
4
4
  * så en härdning aldrig kan landa i ena scriptet och missa det andra.
5
5
  */
6
- import { readFileSync, existsSync } from 'node:fs'
6
+ import { readFileSync, existsSync, statSync } from 'node:fs'
7
7
  import { createHash } from 'node:crypto'
8
8
  import { homedir } from 'node:os'
9
- import { join, resolve, dirname, basename } from 'node:path'
9
+ import { spawnSync } from 'node:child_process'
10
+ import { join, resolve, dirname, basename, isAbsolute, relative } from 'node:path'
10
11
 
11
12
  /**
12
13
  * Claude-konfigkatalogen. Installern hedrar CLAUDE_CONFIG_DIR (#278) — det ska
@@ -116,6 +117,75 @@ export function reportDiffs(diffs) {
116
117
  * källans fetstil- eller `kod`-markeringar är fortfarande ordagrant. */
117
118
  export const norm = (s) => s.replace(/[*`]/g, '').replace(/\s+/g, ' ').trim()
118
119
 
120
+ /** Ett brädvärdes alfabetiska kärna (ADR-0153): NFKC först, sedan bort med
121
+ * allt som inte är en bokstav, gemener. Emoji, blanktecken och skiljetecken
122
+ * avgör aldrig en grind, så `✅ Spec ok` och ` Spec ok ` blir båda `specok`. */
123
+ export const alfabetiskKarna = (s) =>
124
+ s
125
+ .normalize('NFKC')
126
+ .replace(/[^\p{L}]/gu, '')
127
+ .toLowerCase()
128
+
129
+ /** Arkivets ENDA hem (ADR-0154): huvudutcheckningens `.haus-workflow/requests/`,
130
+ * löst från vilken cwd som helst, även en länkad worktree, via
131
+ * `git rev-parse --git-common-dir`. Speglar src/utils/git-root.ts: git < 2.31
132
+ * ignorerar `--path-format=absolute` och skriver ut en relativ sökväg, som då
133
+ * löses mot `--show-toplevel`. `repoRot` är `dirname(commonDir)` bara när
134
+ * commonDir slutar på `.git`. Ett bart repo, en submodul (`<super>/.git/modules/<sub>`)
135
+ * och en `--separate-git-dir`-klon är git-kataloger vars commonDir inte heter
136
+ * `.git`: de ger `{repoRot: null, arkivRot: null, ignorerad: false, commonDir}`.
137
+ * Snedstrecket sist i check-ignore gör att en katalog som ännu inte finns
138
+ * räknas som katalog, så `requests/` i .gitignore träffar den.
139
+ * `kor` är en injicerbar spawnSync (`{status, stdout, stderr}`) för testerna.
140
+ * Varje git-anrop har `timeout: 5000` (som listTrackedArtifactPaths i
141
+ * src/claude/write-gitignore.ts); ett anrop som går ut räknas som att git inte
142
+ * hittade något repo. Kastar aldrig: `null` betyder att git inte hittade något
143
+ * repo eller inte gick att starta.
144
+ * @returns {{repoRot: string, arkivRot: string, ignorerad: boolean}
145
+ * | {repoRot: null, arkivRot: null, ignorerad: false, commonDir: string}
146
+ * | null} */
147
+ export function arkivRot(cwd, { kor = spawnSync } = {}) {
148
+ const git = (args, dir) => {
149
+ try {
150
+ const r = kor('git', args, { cwd: dir, encoding: 'utf8', timeout: 5000 })
151
+ if (!r || r.status !== 0) return null
152
+ const ut = String(r.stdout ?? '').trim()
153
+ return ut || null
154
+ } catch {
155
+ return null
156
+ }
157
+ }
158
+ const absolut = git(['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd)
159
+ let commonDir = absolut && isAbsolute(absolut) ? resolve(absolut) : null
160
+ if (!commonDir) {
161
+ const enkel = git(['rev-parse', '--git-common-dir'], cwd)
162
+ if (!enkel) return null
163
+ if (isAbsolute(enkel)) {
164
+ commonDir = resolve(enkel)
165
+ } else {
166
+ const topp = git(['rev-parse', '--show-toplevel'], cwd)
167
+ if (!topp) return null
168
+ commonDir = resolve(topp, enkel)
169
+ }
170
+ }
171
+ if (basename(commonDir) !== '.git')
172
+ return { repoRot: null, arkivRot: null, ignorerad: false, commonDir }
173
+ const repoRot = dirname(commonDir)
174
+ const ignorerad = (() => {
175
+ try {
176
+ const r = kor('git', ['check-ignore', '-q', '.haus-workflow/requests/'], {
177
+ cwd: repoRot,
178
+ encoding: 'utf8',
179
+ timeout: 5000,
180
+ })
181
+ return !!r && r.status === 0
182
+ } catch {
183
+ return false
184
+ }
185
+ })()
186
+ return { repoRot, arkivRot: join(repoRot, '.haus-workflow', 'requests'), ignorerad }
187
+ }
188
+
119
189
  /** Kanoniserar ett fakturerat modell-id: kontextsuffix som `[1m]` är samma
120
190
  * modell på en annan kontextnivå, och ett datumsuffix `-YYYYMMDD` är samma
121
191
  * modell med utgivningsdatum utskrivet, aldrig en egen modell (ADR-0099, #510). */
@@ -552,6 +622,31 @@ export const avvikelseEtikett = (text) => {
552
622
  return Object.hasOwn(AVVIKELSE_ALIAS, ord) ? AVVIKELSE_ALIAS[ord] : null
553
623
  }
554
624
 
625
+ /**
626
+ * Fritexten efter etiketten på samma rad, utan inledande skiljetecken, eller
627
+ * null när raden saknas eller bara bär etiketten. precheck skriver den som
628
+ * `AVVIKELSE-TEXT:` så orkestratorn kan se en designkonflikt (texten börjar
629
+ * med `design conflict:`, #596) utan att läsa filen själv.
630
+ */
631
+ export const avvikelseText = (text) => {
632
+ const rad = granskareRadRe('Avvikelse mot specen').source
633
+ const m = new RegExp(`${rad}[ \\t]*\\p{L}+[ \\t.,;:!?]*(.*)$`, 'imu').exec(String(text))
634
+ if (!m) return null
635
+ const rest = m[1].trim()
636
+ return rest === '' ? null : rest
637
+ }
638
+
639
+ /**
640
+ * Språket ur källmanifestets `SPRÅK:`-rad (speca steg 2: manifestets första
641
+ * rad), första raden som matchar, annars null. Språket gissas aldrig
642
+ * (ADR-0114, #521): ett saknat eller okänt värde är null, och anroparen
643
+ * vägrar hellre än väljer.
644
+ */
645
+ export const sprakUrManifest = (text) => {
646
+ const m = /^SPRÅK:\s*(svenska|engelska)\s*$/m.exec(String(text ?? ''))
647
+ return m ? m[1] : null
648
+ }
649
+
555
650
  export const UTFALLSVAL_RAD = granskareRow('Obesvarade utfallsval')
556
651
  export const STRUKET_PREFIX = ['struket:', 'struck:']
557
652
 
@@ -873,7 +968,9 @@ export const designFrontmatter = (text) => {
873
968
  if (end === -1) return null
874
969
  const falt = {}
875
970
  for (const l of lines.slice(1, end)) {
876
- const m = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(l)
971
+ // Ett avslutande \r strippas: `.` matchar aldrig \r, så en CRLF-rad
972
+ // föll annars helt och frontmattern blev tom.
973
+ const m = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(l.replace(/\r$/, ''))
877
974
  if (m) falt[m[1]] = m[2].trim()
878
975
  }
879
976
  return falt
@@ -900,6 +997,228 @@ export const specConflicts = (text) => {
900
997
  return ickeTomma
901
998
  }
902
999
 
1000
+ /** Designdokumentets läge (ADR-0156 beslut 4): `draft` när frontmattern bär
1001
+ * `mode: draft` (ociterat), annars `intervju`, även utan frontmatter. */
1002
+ export const designMode = (text) =>
1003
+ designFrontmatter(text)?.mode === 'draft' ? 'draft' : 'intervju'
1004
+
1005
+ /** De icke-tomma raderna i sektionen `rubrik` (ett avslutande \r strippat,
1006
+ * så ett CRLF-dokument tolkas likadant), null när den saknas, och om
1007
+ * sektionen är exakt `none`. En sektion utan en enda icke-tom rad är `tom`:
1008
+ * tolkarna gör den till { fel: 'tom sektion' }, aldrig till none. Delad av
1009
+ * utkastsektionernas tolkare. */
1010
+ const designSektion = (text, rubrik) => {
1011
+ const rader = sectionLines(String(text), rubrik)
1012
+ if (rader === null) return null
1013
+ const ickeTomma = rader.map((r) => r.replace(/\r$/, '')).filter((r) => r.trim() !== '')
1014
+ const none = ickeTomma.length === 1 && ickeTomma[0].trim().toLowerCase() === 'none'
1015
+ return { rader: ickeTomma, none, tom: ickeTomma.length === 0 }
1016
+ }
1017
+ const tomSektion = () => ({ fel: 'tom sektion' })
1018
+
1019
+ const ANTAGANDE_ETIKETTER = [
1020
+ ['Question', 'fraga'],
1021
+ ['Taken', 'vald'],
1022
+ ['Not taken', 'ejValda'],
1023
+ ['Why', 'skal'],
1024
+ ]
1025
+
1026
+ /** `## Assumptions` (ADR-0156 beslut 4): null när sektionen saknas, [] för
1027
+ * `none`, annars en post { fraga, vald, ejValda, skal } per numrerad punkt.
1028
+ * Punkten börjar på kolumn 0 med `N. Question: …`, de tre andra etiketterna
1029
+ * står på egna, indragna rader. `Not taken: none` ger ejValda []. En punkt
1030
+ * utan någon av etiketterna, en etikett två gånger eller en rad som inte hör
1031
+ * till någon punkt ger { fel }, aldrig ett kast. */
1032
+ export const designAssumptions = (text) => {
1033
+ const s = designSektion(text, 'Assumptions')
1034
+ if (s === null) return null
1035
+ if (s.tom) return tomSektion()
1036
+ if (s.none) return []
1037
+ const punkter = []
1038
+ for (const rad of s.rader) {
1039
+ const start = /^\d+\.\s+(.*)$/.exec(rad)
1040
+ const indragen = /^\s+(\S.*)$/.exec(rad)
1041
+ const innehall = start ? start[1] : indragen && punkter.length > 0 ? indragen[1] : null
1042
+ if (innehall === null) return { fel: `raden "${rad.trim()}" hör inte till någon punkt` }
1043
+ if (start) punkter.push({})
1044
+ const n = punkter.length
1045
+ const punkt = punkter[n - 1]
1046
+ const m = /^(Question|Taken|Not taken|Why):\s*(.*)$/.exec(innehall)
1047
+ if (!m) return { fel: `punkt ${n}: raden "${innehall}" har ingen etikett` }
1048
+ if (m[1] in punkt) return { fel: `punkt ${n}: "${m[1]}:" står två gånger` }
1049
+ if (start && m[1] !== 'Question') return { fel: `punkt ${n}: ska börja med "Question:"` }
1050
+ punkt[m[1]] = m[2].trim()
1051
+ }
1052
+ return punkter.reduce((ut, punkt, i) => {
1053
+ if (!Array.isArray(ut)) return ut
1054
+ const saknad = ANTAGANDE_ETIKETTER.find(([etikett]) => !punkt[etikett])
1055
+ if (saknad) return { fel: `punkt ${i + 1}: "${saknad[0]}:" saknas` }
1056
+ const ejValda =
1057
+ punkt['Not taken'].toLowerCase() === 'none'
1058
+ ? []
1059
+ : punkt['Not taken']
1060
+ .split(';')
1061
+ .map((v) => v.trim())
1062
+ .filter(Boolean)
1063
+ return [...ut, { fraga: punkt.Question, vald: punkt.Taken, ejValda, skal: punkt.Why }]
1064
+ }, [])
1065
+ }
1066
+
1067
+ /** Första backtickade token per listpunkt (`- …` eller `* …` på kolumn 0) i
1068
+ * sektionens rader; indragna rader fortsätter föregående punkt. En punkt utan
1069
+ * backticks, en tom token eller en rad utanför alla punkter ger { fel }; en
1070
+ * symbol är aldrig "". */
1071
+ const forstaTokenPerPunkt = (rader) => {
1072
+ const tokens = []
1073
+ for (const rad of rader) {
1074
+ const punkt = /^[-*]\s+(.*)$/.exec(rad)
1075
+ if (!punkt) {
1076
+ if (/^\s/.test(rad) && tokens.length > 0) continue
1077
+ return { fel: `raden "${rad.trim()}" är ingen listpunkt` }
1078
+ }
1079
+ const token = /`([^`]+)`/.exec(punkt[1])
1080
+ if (!token) return { fel: `punkt ${tokens.length + 1}: ingen symbol i backticks` }
1081
+ if (token[1].trim() === '') return { fel: `punkt ${tokens.length + 1}: tom token i backticks` }
1082
+ tokens.push(token[1].trim())
1083
+ }
1084
+ return tokens
1085
+ }
1086
+
1087
+ /** `## Removed or renamed` (ADR-0156 beslut 4): null när sektionen saknas,
1088
+ * [] för `none`, annars symbolen per punkt (``- `gammalt` → `nytt`: skäl``
1089
+ * eller ``- `gammalt`: skäl``, den första backtickade token). En punkt utan
1090
+ * backticks ger { fel }. */
1091
+ export const designRemovedSymbols = (text) => {
1092
+ const s = designSektion(text, 'Removed or renamed')
1093
+ if (s === null) return null
1094
+ if (s.tom) return tomSektion()
1095
+ if (s.none) return []
1096
+ return forstaTokenPerPunkt(s.rader)
1097
+ }
1098
+
1099
+ /** `## Files touched` (ADR-0156 beslut 4): null när sektionen saknas, annars
1100
+ * den första backtickade token per punkt, relativa sökvägar som skrivna.
1101
+ * `none` är ingen listpunkt och ger { fel }, som en punkt utan backticks. */
1102
+ export const designFilesTouched = (text) => {
1103
+ const s = designSektion(text, 'Files touched')
1104
+ if (s === null) return null
1105
+ if (s.tom) return tomSektion()
1106
+ return forstaTokenPerPunkt(s.rader)
1107
+ }
1108
+
1109
+ /** En post under "## Files touched" som är en befintlig katalog under
1110
+ * `repoRot` (`src/`, `.`): som undantag i symbolkontrollen hade den tystat
1111
+ * hela trädet. Ger felet för den första, null när ingen är en katalog. */
1112
+ const katalogIFilerna = (filer, repoRot) => {
1113
+ const rot = resolve(repoRot)
1114
+ for (const [i, p] of filer.entries()) {
1115
+ const abs = resolve(rot, p)
1116
+ const rel = relative(rot, abs)
1117
+ if (rel.startsWith('..') || isAbsolute(rel)) continue
1118
+ let arKatalog = false
1119
+ try {
1120
+ arKatalog = statSync(abs).isDirectory()
1121
+ } catch {
1122
+ arKatalog = false
1123
+ }
1124
+ if (arKatalog) return `punkt ${i + 1}: "${p}" är en katalog, inte en fil`
1125
+ }
1126
+ return null
1127
+ }
1128
+
1129
+ /** Utkastsektionernas formkontroll (ADR-0156 beslut 4), delad av precheck
1130
+ * --design, precheck --design-utkast och add-design. En saknad eller
1131
+ * felformad sektion är BRIST på ett utkast och VARNING på ett
1132
+ * intervjudokument (de tre befintliga designerna saknar sektionerna; där
1133
+ * säger raden att sektionen är valfri). Med `repoRot` är en post under
1134
+ * "## Files touched" som är en katalog i repot felformad. `## Spec
1135
+ * conflicts` prövar anroparna som förut. */
1136
+ export const designForm = (text, { repoRot = null } = {}) => {
1137
+ const lage = designMode(text)
1138
+ const fel = (r) => (r === null ? 'saknas' : !Array.isArray(r) ? r.fel : null)
1139
+ const filer = designFilesTouched(text)
1140
+ const filFel =
1141
+ Array.isArray(filer) && repoRot != null ? katalogIFilerna(filer, repoRot) : fel(filer)
1142
+ const rader = [
1143
+ ['Assumptions', fel(designAssumptions(text))],
1144
+ ['Removed or renamed', fel(designRemovedSymbols(text))],
1145
+ ['Files touched', filFel],
1146
+ ]
1147
+ .filter(([, f]) => f !== null)
1148
+ .map(([namn, f]) =>
1149
+ lage !== 'draft' && f === 'saknas'
1150
+ ? `design.md: sektionen "## ${namn}" saknas (valfri på ett intervjudokument)`
1151
+ : `design.md: sektionen "## ${namn}" saknas eller är felformad (${f})`,
1152
+ )
1153
+ return lage === 'draft'
1154
+ ? { lage, brister: rader, varningar: [] }
1155
+ : { lage, brister: [], varningar: rader }
1156
+ }
1157
+
1158
+ /** Repo-roten för en fil: närmaste förälder som innehåller `.git` (en
1159
+ * katalog, eller en fil i en länkad worktree), null utan. Inget git-anrop. */
1160
+ export const repoRotForFil = (abs) => {
1161
+ let cur = dirname(resolve(abs))
1162
+ for (;;) {
1163
+ if (existsSync(join(cur, '.git'))) return cur
1164
+ const parent = dirname(cur)
1165
+ if (parent === cur) return null
1166
+ cur = parent
1167
+ }
1168
+ }
1169
+
1170
+ /** Symbolkontrollen (ADR-0156 beslut 4): ett `git grep -n -w -F -I -e <symbol>`
1171
+ * per symbol i `repoRot` (-w: en hel identifierare, så `old` träffar aldrig
1172
+ * `oldName`), med varje sökväg i `undantag` (filerna designen
1173
+ * rör) och designdokumentens hem `docs/superpowers/specs` undantagna. Status
1174
+ * 1 är ingen träff, status 0 ger första raden som { symbol, fil, rad }. Ett
1175
+ * annat status, ett spawnfel (timeout 5000 ms) eller ett kast fyller `fel`
1176
+ * och stoppar; anroparen gör det till en VARNING, aldrig en tyst passage.
1177
+ * `kor` är en injicerbar spawnSync, som i arkivRot.
1178
+ * @returns {{traffar: {symbol: string, fil: string, rad: number|null}[], fel: string|null}} */
1179
+ export function symbolkontroll({ symboler, undantag, repoRot, kor = spawnSync }) {
1180
+ const traffar = []
1181
+ const exclude = [...undantag, 'docs/superpowers/specs'].map((p) => `:(exclude)${p}`)
1182
+ for (const symbol of symboler) {
1183
+ let r
1184
+ try {
1185
+ r = kor('git', ['grep', '-n', '-w', '-F', '-I', '-e', symbol, '--', '.', ...exclude], {
1186
+ cwd: repoRot,
1187
+ encoding: 'utf8',
1188
+ timeout: 5000,
1189
+ })
1190
+ } catch (e) {
1191
+ return { traffar, fel: e.message }
1192
+ }
1193
+ if (r?.error) return { traffar, fel: r.error.message }
1194
+ if (r?.status === 1) continue
1195
+ if (r?.status !== 0) {
1196
+ const stderr = String(r?.stderr ?? '').trim()
1197
+ return { traffar, fel: stderr || `git grep avslutades med status ${r?.status}` }
1198
+ }
1199
+ const forsta = String(r.stdout ?? '').split('\n')[0]
1200
+ const m = /^(.+?):(\d+):/.exec(forsta)
1201
+ traffar.push(m ? { symbol, fil: m[1], rad: Number(m[2]) } : { symbol, fil: forsta, rad: null })
1202
+ }
1203
+ return { traffar, fel: null }
1204
+ }
1205
+
1206
+ /** Symbolkontrollen för ett designdokument, delad av precheck och
1207
+ * add-design: symbolerna under "## Removed or renamed" greppas i `repoRot`
1208
+ * utanför filerna under "## Files touched". Inga symboler: inget grep. Ingen
1209
+ * rot eller ett git-fel ger `varning` (anroparen skriver VARNING, aldrig en
1210
+ * tyst passage); träffarna mappar anroparen till sin egen rad. */
1211
+ export const designSymbolkontroll = (text, repoRot, { kor = spawnSync } = {}) => {
1212
+ const symboler = designRemovedSymbols(text)
1213
+ if (!Array.isArray(symboler) || symboler.length === 0) return { traffar: [], varning: null }
1214
+ if (repoRot == null)
1215
+ return { traffar: [], varning: 'symbolkontrollen kunde inte köras: ingen repo-rot' }
1216
+ const filer = designFilesTouched(text)
1217
+ const undantag = Array.isArray(filer) ? filer : []
1218
+ const { traffar, fel } = symbolkontroll({ symboler, undantag, repoRot, kor })
1219
+ return { traffar, varning: fel ? `symbolkontrollen kunde inte köras: ${fel}` : null }
1220
+ }
1221
+
903
1222
  /** Katalogens id: gid:t ur state.json, annars katalognamnet (en slug-katalog
904
1223
  * bär request_id null). En definition, delad av add-design och precheck
905
1224
  * --design, så frontmatterns request_id alltid prövas mot samma svar. */
@@ -1114,6 +1433,12 @@ export const FRYSTA_LITERALER = Object.freeze({
1114
1433
  // ADR-0144: paraplyläget i specifierns prompt (bedöms DELA_UPP-val 2).
1115
1434
  'PARAPLY:',
1116
1435
  'DELAR:',
1436
+ // ADR-0156: designerns och designgranskarens promptetiketter.
1437
+ 'URSPRUNGLIG FÖRFRÅGAN:',
1438
+ 'KONFIG:',
1439
+ 'ID:',
1440
+ 'SPEC-SHA256:',
1441
+ 'DATUM:',
1117
1442
  ]),
1118
1443
  promptvarden: Object.freeze([
1119
1444
  'installerade',
@@ -10,12 +10,17 @@
10
10
  * blick (poängord i analys, saknad Ingår inte) — de skrivs ut men stoppar inget.
11
11
  *
12
12
  * Användning: node precheck.mjs <arkivkatalog> [--impact] [--design]
13
+ * [--design-utkast <abs fil> [--repo-rot <abs>]]
13
14
  * --impact kräv och kontrollera även impact.md (bedöm-steget)
14
15
  * --design kräv och kontrollera även design.md (designa-steget och bedöms grind 1b)
16
+ * --design-utkast kontrollera ett designutkast utan state.design (experimentkanalen,
17
+ * ADR-0156): sektionerna, frontmattern, Spec conflicts och symbolkontrollen
18
+ * --repo-rot roten symbolkontrollen greppar i; vinner när den ges, annars repot
19
+ * utkastet ligger i
15
20
  * Exit 0 = ok, 1 = brister (listade på stdout), 2 = användningsfel.
16
21
  */
17
22
  import { readFileSync, existsSync } from 'node:fs'
18
- import { join } from 'node:path'
23
+ import { join, isAbsolute } from 'node:path'
19
24
 
20
25
  import {
21
26
  markerStrippedSha256,
@@ -26,6 +31,7 @@ import {
26
31
  specForm,
27
32
  headingsByLanguage,
28
33
  avvikelseEtikett,
34
+ avvikelseText,
29
35
  AVVIKELSE_ETIKETTER,
30
36
  AVVIKELSE_ALIAS,
31
37
  STRUKET_PREFIX,
@@ -43,15 +49,33 @@ import {
43
49
  designBody,
44
50
  specConflicts,
45
51
  katalogId,
52
+ designForm,
53
+ designSymbolkontroll,
54
+ repoRotForFil,
46
55
  } from './lib.mjs'
47
56
 
48
57
  const dir = process.argv[2]
49
58
  const requireImpact = process.argv.includes('--impact')
50
59
  const requireDesign = process.argv.includes('--design')
60
+ const ANVANDNING =
61
+ 'användning: node precheck.mjs <arkivkatalog> [--impact] [--design] [--design-utkast <abs fil> [--repo-rot <abs>]]'
51
62
  if (!dir) {
52
- console.error('användning: node precheck.mjs <arkivkatalog> [--impact] [--design]')
63
+ console.error(ANVANDNING)
53
64
  process.exit(2)
54
65
  }
66
+ /** Värdet efter en sökvägsflagga: en absolut sökväg, annars användningsfel. */
67
+ const sokvagsflagga = (namn) => {
68
+ const i = process.argv.indexOf(namn)
69
+ if (i === -1) return null
70
+ const v = process.argv[i + 1]
71
+ if (v === undefined || v.startsWith('--') || !isAbsolute(v)) {
72
+ console.error(`${namn} kräver en absolut sökväg\n${ANVANDNING}`)
73
+ process.exit(2)
74
+ }
75
+ return v
76
+ }
77
+ const designUtkast = sokvagsflagga('--design-utkast')
78
+ const repoRotFlagga = sokvagsflagga('--repo-rot')
55
79
 
56
80
  const problems = []
57
81
  const warnings = []
@@ -59,9 +83,11 @@ const warnings = []
59
83
  // ADR-0106, #505): `PRISAVGORANDE: <text>` när granskarblocket flaggar ett
60
84
  // prisavgörande öppet val; `KANDIDAT: <utfall>` per block som bär markören,
61
85
  // alltid, oavsett exitkod; `AVVIKELSE: <etikett>` när --impact hittar en
62
- // giltig etikett.
86
+ // giltig etikett, och `AVVIKELSE-TEXT: <fritext>` efter den när raden bär text
87
+ // efter etiketten (#596: en designkonflikt läses ur raden, aldrig ur filen).
63
88
  const kandidater = []
64
89
  let avvikelse = null
90
+ let avvikelseFritext = null
65
91
  let prisavgorande = null
66
92
 
67
93
  // Kontraktsdriftvakt: rubrik- och vokabulärreglerna nedan speglar specformatet.
@@ -205,6 +231,7 @@ if (requireImpact) {
205
231
  const avvikelseRad = granskareRow('Avvikelse mot specen')[dokumentSprak]
206
232
  const utfallsvalRad = granskareRow('Obesvarade utfallsval')[dokumentSprak]
207
233
  avvikelse = avvikelseEtikett(impact)
234
+ avvikelseFritext = avvikelseText(impact)
208
235
  if (avvikelse == null)
209
236
  problems.push(
210
237
  `impact.md: raden "**${avvikelseRad}:**" ska börja med en etikett: ${AVVIKELSE_ETIKETTER.map(v.etikett).join(', ')}`,
@@ -257,6 +284,66 @@ if (requireImpact) {
257
284
  // ligger i state.json-blocket nedan.
258
285
  const designText = requireDesign ? read('design.md', true) : null
259
286
  let designRad = null
287
+ // `DESIGN-LÄGE: draft|intervju` (ADR-0156 beslut 4), satt när ett
288
+ // designdokument formprövats; skrivs bland kontraktsraderna.
289
+ let designLage = null
290
+
291
+ /** Frontmattern, Spec conflicts och tids- och poängorden: kontrollerna som
292
+ * gäller dokumentets innehåll, delade av --design och --design-utkast så
293
+ * utkastet prövas med exakt samma rader. `id` är katalogens id. */
294
+ const granskaDesignInnehall = (text, id) => {
295
+ const fm = designFrontmatter(text) ?? {}
296
+ if (fm.request_id !== id)
297
+ problems.push(
298
+ `design.md: frontmatterns request_id "${fm.request_id ?? ''}" är inte katalogens id "${id}"`,
299
+ )
300
+ if (spec != null && fm.spec_sha256 !== markerStrippedSha256(join(dir, 'spec.md')))
301
+ problems.push(
302
+ 'design.md: spec_sha256 är inte nuvarande spec.md, designen gjordes mot en annan spec',
303
+ )
304
+ const konflikter = specConflicts(text)
305
+ if (konflikter === null) problems.push('design.md: sektionen "## Spec conflicts" saknas')
306
+ else if (konflikter.length > 0)
307
+ problems.push(
308
+ `design.md: "## Spec conflicts" är inte none (${konflikter.length}), posten går till kunddialog och respec`,
309
+ )
310
+ // Storleks- och poängord i designen är VARNING, aldrig BRIST: dokumentet
311
+ // skrivs av en människostyrd session och assessorn instrueras att
312
+ // bortse från dem, men granskaren ska se dem. Bara brödtexten, aldrig
313
+ // frontmattern (en hash kan råka innehålla "tim").
314
+ const body = designBody(text)
315
+ const p = body.match(POANGORD_RE)
316
+ if (p)
317
+ warnings.push(
318
+ `design.md: poängord "${p[0]}", assessorn ska bortse från det, granskaren bör se det`,
319
+ )
320
+ const t = body.match(TIDSORD_RE)
321
+ if (t)
322
+ warnings.push(
323
+ `design.md: tidsord "${t[0]}", assessorn ska bortse från det, granskaren bör se det`,
324
+ )
325
+ }
326
+
327
+ /** Utkastsektionerna och symbolkontrollen (ADR-0156 beslut 4): designForm
328
+ * ger BRIST på ett utkast och VARNING på ett intervjudokument; varje symbol
329
+ * under "## Removed or renamed" greppas i `repoRot` utanför filerna under
330
+ * "## Files touched". En träff är BRIST; en kontroll som inte kunde köras
331
+ * (ingen rot, git-fel, timeout) är VARNING, aldrig en tyst passage.
332
+ * `symboler: false` hoppar över greppet (--design utan kanonisk fil, som
333
+ * redan är en BRIST). */
334
+ const granskaUtkastform = (text, repoRot, { symboler: greppa = true } = {}) => {
335
+ const form = designForm(text, { repoRot })
336
+ designLage = form.lage
337
+ problems.push(...form.brister)
338
+ warnings.push(...form.varningar)
339
+ if (!greppa) return
340
+ const { traffar, varning } = designSymbolkontroll(text, repoRot)
341
+ for (const t of traffar)
342
+ problems.push(
343
+ `symbolen \`${t.symbol}\` används fortfarande: ${t.fil}:${t.rad} (utanför filerna designen rör)`,
344
+ )
345
+ if (varning) warnings.push(varning)
346
+ }
260
347
 
261
348
  // ---- state.json ------------------------------------------------------------
262
349
  const state = read('state.json', true)
@@ -349,50 +436,63 @@ if (state != null) {
349
436
  `design.md: den kanoniska filen ${design.path} har en annan hash än design.md`,
350
437
  )
351
438
  }
352
- const fm = designFrontmatter(designText) ?? {}
353
- const id = katalogId(s, dir)
354
- if (fm.request_id !== id)
355
- problems.push(
356
- `design.md: frontmatterns request_id "${fm.request_id ?? ''}" är inte katalogens id "${id}"`,
357
- )
358
- if (spec != null && fm.spec_sha256 !== markerStrippedSha256(join(dir, 'spec.md')))
359
- problems.push(
360
- 'design.md: spec_sha256 är inte nuvarande spec.md, designen gjordes mot en annan spec',
361
- )
362
- const konflikter = specConflicts(designText)
363
- if (konflikter === null) problems.push('design.md: sektionen "## Spec conflicts" saknas')
364
- else if (konflikter.length > 0)
365
- problems.push(
366
- `design.md: "## Spec conflicts" är inte none (${konflikter.length}), posten går till kunddialog och respec`,
367
- )
368
- // Storleks- och poängord i designen är VARNING, aldrig BRIST: dokumentet
369
- // skrivs av en människostyrd session och assessorn instrueras att
370
- // bortse från dem, men granskaren ska se dem. Bara brödtexten, aldrig
371
- // frontmattern (en hash kan råka innehålla "tim").
372
- const body = designBody(designText)
373
- const p = body.match(POANGORD_RE)
374
- if (p)
375
- warnings.push(
376
- `design.md: poängord "${p[0]}", assessorn ska bortse från det, granskaren bör se det`,
377
- )
378
- const t = body.match(TIDSORD_RE)
379
- if (t)
380
- warnings.push(
381
- `design.md: tidsord "${t[0]}", assessorn ska bortse från det, granskaren bör se det`,
382
- )
439
+ granskaDesignInnehall(designText, katalogId(s, dir))
383
440
  }
441
+ // ADR-0156 beslut 4: utkastsektionerna prövas när design.md finns;
442
+ // symbolkontrollen körs i repot där den kanoniska filen bor, bara när
443
+ // filen finns (annars står redan en BRIST om den ovan).
444
+ if (designText != null) {
445
+ const kanonisk =
446
+ design !== null && typeof design.path === 'string' && existsSync(design.path)
447
+ ? design.path
448
+ : null
449
+ granskaUtkastform(designText, kanonisk === null ? null : repoRotForFil(kanonisk), {
450
+ symboler: kanonisk !== null,
451
+ })
452
+ }
453
+ if (design?.review?.verdict === 'stopp')
454
+ problems.push(
455
+ `designgranskningen stoppade utkastet (design-review.md): kör designa ${katalogId(s, dir)}`,
456
+ )
384
457
  }
385
458
  } catch {
386
459
  problems.push('state.json: ogiltig JSON')
387
460
  }
388
461
  }
389
462
 
463
+ // ---- --design-utkast (experimentkanalen, ADR-0156) -------------------------
464
+ // Ett designutkast som aldrig bokförts: ingen state.design läses, filen pekas
465
+ // ut direkt. Samma innehålls- och formrader som --design; symbolkontrollen i
466
+ // --repo-rot när den är given (utkastet kan ligga i arkivets repo, koden i
467
+ // målrepot), annars i repot filen ligger i.
468
+ if (designUtkast != null) {
469
+ let text = null
470
+ try {
471
+ text = readFileSync(designUtkast, 'utf8')
472
+ } catch (e) {
473
+ problems.push(`${designUtkast}: går inte att läsa: ${e.message}`)
474
+ }
475
+ if (text != null) {
476
+ let s = null
477
+ try {
478
+ s = state != null ? JSON.parse(state) : null
479
+ } catch {
480
+ // state.json-blocket ovan har redan fällt den ogiltiga JSON:en.
481
+ }
482
+ granskaDesignInnehall(text, katalogId(s, dir))
483
+ granskaUtkastform(text, repoRotFlagga ?? repoRotForFil(designUtkast))
484
+ }
485
+ }
486
+
390
487
  // Kontraktsraderna först, före varningar och verdikt: orkestratorn läser dem
391
488
  // innan den tolkar exitkoden.
392
489
  if (prisavgorande != null) console.log(`PRISAVGORANDE: ${prisavgorande}`)
393
490
  for (const k of kandidater) console.log(`KANDIDAT: ${k}`)
394
491
  if (avvikelse != null) console.log(`AVVIKELSE: ${avvikelse}`)
492
+ if (avvikelse != null && avvikelseFritext != null)
493
+ console.log(`AVVIKELSE-TEXT: ${avvikelseFritext}`)
395
494
  if (requireDesign) console.log(`DESIGN: ${designRad ?? 'saknas'}`)
495
+ if (designLage != null) console.log(`DESIGN-LÄGE: ${designLage}`)
396
496
  for (const w of warnings) console.log(`VARNING: ${w}`)
397
497
  if (problems.length === 0) {
398
498
  console.log('OK: formkontrollen passerad')