@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.
- package/CHANGELOG.md +55 -0
- package/README.md +7 -4
- package/dist/cli.js +7183 -4310
- package/library/catalog/manifest.json +126 -9
- package/library/global/agents/request-design-reviewer.md +54 -0
- package/library/global/agents/request-designer.md +113 -0
- package/library/global/skills/haus-workflow/SKILL.md +7 -3
- package/library/global/skills/haus-workflow/references/init.md +7 -4
- package/library/global/skills/request/SKILL.md +487 -104
- package/library/global/skills/request/scripts/archive.mjs +697 -48
- package/library/global/skills/request/scripts/bedom.mjs +651 -0
- package/library/global/skills/request/scripts/korning.mjs +911 -70
- package/library/global/skills/request/scripts/lib.mjs +328 -3
- package/library/global/skills/request/scripts/precheck.mjs +134 -34
- package/library/global/skills/request/scripts/svep.mjs +44 -14
- package/library/global/skills/request-assess-impact/SKILL.md +1 -4
- package/library/global/skills/request-sync/SKILL.md +8 -2
- package/package.json +1 -1
|
@@ -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 {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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')
|