spexcode 0.4.0 → 0.4.3
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/README.md +37 -17
- package/package.json +9 -5
- package/spec-cli/README.md +23 -14
- package/spec-cli/hooks/dispatch.sh +7 -5
- package/spec-cli/hooks/harness.sh +19 -8
- package/spec-cli/src/anchors.ts +163 -30
- package/spec-cli/src/cli.ts +97 -42
- package/spec-cli/src/client.ts +34 -11
- package/spec-cli/src/doctor.ts +174 -36
- package/spec-cli/src/gateway-auth.ts +194 -0
- package/spec-cli/src/gateway-hub.ts +360 -0
- package/spec-cli/src/gateway.ts +31 -16
- package/spec-cli/src/git.ts +12 -5
- package/spec-cli/src/graph.ts +53 -36
- package/spec-cli/src/graphStream.ts +265 -34
- package/spec-cli/src/guide.ts +157 -76
- package/spec-cli/src/harness-select.ts +20 -6
- package/spec-cli/src/harness.ts +266 -67
- package/spec-cli/src/help.ts +84 -41
- package/spec-cli/src/host.ts +610 -0
- package/spec-cli/src/identity-presets.d.ts +13 -0
- package/spec-cli/src/identity-presets.js +128 -0
- package/spec-cli/src/index.ts +163 -86
- package/spec-cli/src/init.ts +53 -25
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +115 -169
- package/spec-cli/src/login-page.ts +12 -5
- package/spec-cli/src/materialize.ts +39 -13
- package/spec-cli/src/mentions.ts +16 -11
- package/spec-cli/src/opencode.ts +145 -0
- package/spec-cli/src/pi-harness.ts +124 -0
- package/spec-cli/src/project-identity.ts +73 -0
- package/spec-cli/src/pty-bridge.ts +321 -521
- package/spec-cli/src/pty-helper.mjs +86 -0
- package/spec-cli/src/reaper.ts +16 -8
- package/spec-cli/src/reviewSnapshot.ts +23 -0
- package/spec-cli/src/reviews.ts +347 -0
- package/spec-cli/src/session-timeline.ts +162 -0
- package/spec-cli/src/sessions.ts +210 -63
- package/spec-cli/src/shim-runtime.ts +169 -0
- package/spec-cli/src/slash-commands.ts +76 -0
- package/spec-cli/src/source-files.ts +79 -0
- package/spec-cli/src/specs.ts +47 -24
- package/spec-cli/src/supervise.ts +37 -15
- package/spec-cli/src/tree.ts +8 -8
- package/spec-cli/src/uninstall.ts +56 -23
- package/spec-cli/templates/hooks/prepare-commit-msg +32 -13
- package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +24 -18
- package/spec-cli/templates/spec/project/.plugins/{regroup → commands/regroup}/spec.md +3 -3
- package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
- package/spec-cli/templates/spec/project/.plugins/commands/spec.md +30 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
- package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
- package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
- package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +62 -0
- package/spec-cli/templates/spec/project/.plugins/skills/spec.md +19 -0
- package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
- package/spec-cli/templates/spec/project/spec.md +6 -5
- package/spec-cli/templates/spexcode.json +4 -2
- package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
- package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
- package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
- package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
- package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
- package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
- package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
- package/spec-dashboard/dist/assets/index-GGIVdKwH.css +1 -0
- package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
- package/spec-dashboard/dist/index.html +10 -10
- package/spec-dashboard/src/reviewFilters.js +279 -0
- package/spec-dashboard/src/reviewQuery.js +174 -0
- package/spec-dashboard/src/session.js +167 -0
- package/spec-eval/src/cli.ts +80 -45
- package/spec-eval/src/evaltab.ts +4 -3
- package/spec-eval/src/humanok.ts +1 -1
- package/spec-eval/src/matrix.ts +693 -0
- package/spec-eval/src/scenarios.ts +116 -4
- package/spec-eval/src/sessioneval.ts +726 -66
- package/spec-cli/src/migrate-table.ts +0 -398
- package/spec-cli/src/migrate.ts +0 -386
- package/spec-cli/templates/spec/project/.plugins/distill/spec.md +0 -74
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +0 -8
- package/spec-cli/templates/spec/project/.plugins/tidy/spec.md +0 -25
- package/spec-dashboard/dist/assets/Dashboard-CTcH2eW9.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +0 -2
- package/spec-dashboard/dist/assets/FoldToggle-CVFbBpyW.js +0 -1
- package/spec-dashboard/dist/assets/IssuesPage-kULjonqj.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-B0ZJju8K.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-BRKJqU2U.js +0 -71
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
- package/spec-dashboard/dist/assets/SessionWindow-CDhEL7wO.js +0 -9
- package/spec-dashboard/dist/assets/Settings-BL6FV_8S.js +0 -1
- package/spec-dashboard/dist/assets/index-DmQsNYKK.css +0 -1
- package/spec-dashboard/dist/assets/index-DulGPk6A.js +0 -41
- /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/digest.mjs +0 -0
package/spec-cli/src/init.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { join, resolve, relative } from 'node:path'
|
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
import { execFileSync } from 'node:child_process'
|
|
5
5
|
import { readConfig, readJsonConfig } from './layout.js'
|
|
6
|
-
import { resolveHarnessTargets } from './harness-select.js'
|
|
6
|
+
import { resolveHarnessTargets, parseHarnessFlag, NATIVE_HARNESS_IDS } from './harness-select.js'
|
|
7
7
|
|
|
8
8
|
// this file lives at <pkgRoot>/src/init.ts, so `..` is the package root — the same derivation the
|
|
9
9
|
// launch paths use, never a hardcoded repo path (so a relocated/installed package still finds its data).
|
|
@@ -54,7 +54,7 @@ function resolveHooksDir(dir: string): string | null {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
export async function specInit(targetArg: string | undefined, presetArg?: string): Promise<void> {
|
|
57
|
+
export async function specInit(targetArg: string | undefined, presetArg?: string, harnessArg?: string): Promise<void> {
|
|
58
58
|
const targetDir = resolve(targetArg ?? process.cwd())
|
|
59
59
|
|
|
60
60
|
// the preset the NEW adopter gets — `--preset <name>` wins, else an existing target spexcode.json's
|
|
@@ -78,6 +78,24 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
|
|
|
78
78
|
process.exit(1)
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
// the harness DELIVERY TARGET set ([[harness-select]]) is a REQUIRED, explicit choice — `--harness <ids>`
|
|
82
|
+
// stamps it into spexcode.json; absent the flag, a pre-existing explicit `harnesses` field IS the choice.
|
|
83
|
+
// Neither → abort BEFORE writing anything: with many harnesses, a silent "deliver to all" would litter the
|
|
84
|
+
// adopter's tree (and global tool configs) with artifacts for harnesses they never installed. Legality
|
|
85
|
+
// (unknown ids, plugin exclusivity, empty set) fails loud here too, not as a soft materialize warning.
|
|
86
|
+
const flagRaw = (harnessArg ?? '').trim() ? parseHarnessFlag(harnessArg!.trim()) : null
|
|
87
|
+
const chosenHarnesses = flagRaw ?? readConfig(targetDir).harnesses ?? null
|
|
88
|
+
if (chosenHarnesses === null) {
|
|
89
|
+
console.error(`spex init: --harness is required — name the harness(es) this repo delivers into, e.g. \`spex init --harness claude\`. Known native ids: ${NATIVE_HARNESS_IDS.join(', ')} (comma-separate several; a plugin bundle: --harness plugin:<folder>). A pre-existing spexcode.json "harnesses" field also satisfies this.`)
|
|
90
|
+
process.exit(1)
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
resolveHarnessTargets(chosenHarnesses)
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.error(`spex init: ${(e as Error).message}`)
|
|
96
|
+
process.exit(1)
|
|
97
|
+
}
|
|
98
|
+
|
|
81
99
|
if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true })
|
|
82
100
|
console.log(`spex init → ${targetDir}`)
|
|
83
101
|
|
|
@@ -100,27 +118,35 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
|
|
|
100
118
|
}
|
|
101
119
|
}
|
|
102
120
|
|
|
103
|
-
// 1b. plant a starter spexcode.json (the lint/layout knob)
|
|
104
|
-
// template
|
|
105
|
-
//
|
|
121
|
+
// 1b. plant a starter spexcode.json (the lint/layout knob) carrying the CHOSEN `harnesses` set. The
|
|
122
|
+
// template ships launchers for every native harness; seeding keeps only the SELECTED ones (a launcher for
|
|
123
|
+
// a tool the adopter didn't pick is exactly the litter --harness exists to prevent) — a plugin-only
|
|
124
|
+
// selection keeps them all, since the bundle serves the HOST agent while dispatched sessions still need a
|
|
125
|
+
// launcher. The success message reports values read back from the planted file, never restated literals.
|
|
106
126
|
const cfgDest = join(targetDir, 'spexcode.json')
|
|
127
|
+
const nativeChosen = (chosenHarnesses as unknown[]).filter((m): m is string => typeof m === 'string')
|
|
107
128
|
if (existsSync(cfgDest)) {
|
|
108
|
-
|
|
129
|
+
if (flagRaw) {
|
|
130
|
+
// an explicit --harness on a re-init is a deliberate command: restamp THAT field, touch nothing else.
|
|
131
|
+
const cfg = (readJsonConfig(cfgDest) ?? {}) as Record<string, unknown>
|
|
132
|
+
cfg.harnesses = flagRaw
|
|
133
|
+
writeFileSync(cfgDest, JSON.stringify(cfg, null, 2) + '\n')
|
|
134
|
+
console.log(`✓ stamped "harnesses": ${JSON.stringify(flagRaw)} into the existing spexcode.json (other fields untouched)`)
|
|
135
|
+
} else {
|
|
136
|
+
console.warn(`• spexcode.json already exists at ${cfgDest} — left untouched (harnesses: ${JSON.stringify(chosenHarnesses)}).`)
|
|
137
|
+
}
|
|
109
138
|
} else {
|
|
110
|
-
|
|
139
|
+
const cfg = (readJsonConfig(join(TEMPLATES, 'spexcode.json')) ?? {}) as Record<string, any>
|
|
140
|
+
cfg.harnesses = chosenHarnesses
|
|
141
|
+
if (nativeChosen.length && cfg.sessions?.launchers) {
|
|
142
|
+
cfg.sessions.launchers = Object.fromEntries(
|
|
143
|
+
Object.entries(cfg.sessions.launchers as Record<string, { harness?: string }>).filter(([, l]) => nativeChosen.includes(l.harness ?? 'claude')))
|
|
144
|
+
const names = Object.keys(cfg.sessions.launchers)
|
|
145
|
+
if (names.length) cfg.sessions.defaultLauncher = names[0]
|
|
146
|
+
}
|
|
147
|
+
writeFileSync(cfgDest, JSON.stringify(cfg, null, 2) + '\n')
|
|
111
148
|
const roots = JSON.stringify(readJsonConfig(cfgDest)?.lint?.governedRoots ?? null)
|
|
112
|
-
console.log(`✓ planted spexcode.json — lint.governedRoots starts as ${roots} (the whole git-tracked tree, tests excluded)
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// validate the harness DELIVERY TARGET set ([[harness-select]]) up front: a bad `harnesses` set (plugin +
|
|
116
|
-
// native, or a plugin with no folder) must fail LOUD here, not be silently swallowed by the materialize
|
|
117
|
-
// try/catch below. A fresh starter spexcode.json omits the field (defaults to all natives), so this only
|
|
118
|
-
// bites a hand-edited or re-init'd config — exactly where a clear error belongs.
|
|
119
|
-
try {
|
|
120
|
-
resolveHarnessTargets(readConfig(targetDir).harnesses)
|
|
121
|
-
} catch (e) {
|
|
122
|
-
console.error(`spex init: ${(e as Error).message}`)
|
|
123
|
-
process.exit(1)
|
|
149
|
+
console.log(`✓ planted spexcode.json — harnesses ${JSON.stringify(chosenHarnesses)}, launchers ${JSON.stringify(Object.keys(cfg.sessions?.launchers ?? {}))}; lint.governedRoots starts as ${roots} (the whole git-tracked tree, tests excluded)`)
|
|
124
150
|
}
|
|
125
151
|
|
|
126
152
|
// 2. install the git hooks: templates/hooks/* -> <repo>/<common-git-dir>/hooks/* (skip any that exist).
|
|
@@ -145,18 +171,20 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
|
|
|
145
171
|
if (installed.length) console.log(`✓ installed git hooks (${installed.join(', ')}) → ${hooksDir}`)
|
|
146
172
|
}
|
|
147
173
|
|
|
148
|
-
// 2c. MATERIALIZE the harness-discovered artifacts so a USER-self-launched
|
|
149
|
-
// steps
|
|
150
|
-
// <spexcode> contract block (user content preserved), the .claude/.codex shims, and the Codex trust (global,
|
|
151
|
-
// scoped) so codex self-launch is prompt-free. Runs with cwd = the target so the loaders read the just-seeded
|
|
174
|
+
// 2c. MATERIALIZE the harness-discovered artifacts so a USER-self-launched harness works with zero further
|
|
175
|
+
// steps. Runs with cwd = the target so the loaders read the just-seeded
|
|
152
176
|
// .plugins. Idempotent — the planted git hooks (pre-commit/post-checkout/post-merge) keep it fresh
|
|
153
177
|
// thereafter on the git-native anchors ([[commit-surgery]]); no harness event ever triggers a materialize.
|
|
154
178
|
const prevCwd = process.cwd()
|
|
155
179
|
try {
|
|
156
180
|
process.chdir(targetDir)
|
|
157
181
|
const { materialize } = await import('./materialize.js')
|
|
158
|
-
materialize(targetDir)
|
|
159
|
-
|
|
182
|
+
const result = materialize(targetDir)
|
|
183
|
+
const display = (path: string) => {
|
|
184
|
+
const local = relative(targetDir, path)
|
|
185
|
+
return local && local !== '..' && !local.startsWith('../') && !local.startsWith('..\\') ? local : path
|
|
186
|
+
}
|
|
187
|
+
console.log(`✓ materialized harness artifacts (${result.planted.map((a) => `${a.kind}: ${display(a.path)}`).join(', ')})`)
|
|
160
188
|
} catch (e) {
|
|
161
189
|
console.warn(`• materialize skipped (${(e as Error).message}) — run \`spex materialize\` once the packages are installed.`)
|
|
162
190
|
} finally {
|
package/spec-cli/src/layout.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { join, dirname } from 'node:path'
|
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
import { git, repoRoot, gitA, headSha, worktreeSpecSig, worktreeSpecDelta, type NodeOp } from './git.js'
|
|
5
5
|
import { guardWorktree } from './resilience.js'
|
|
6
|
-
import { HARNESSES } from './harness.js'
|
|
6
|
+
import { HARNESSES, type HarnessId } from './harness.js'
|
|
7
7
|
|
|
8
8
|
type Config = {
|
|
9
9
|
main?: string // path to the source-of-truth checkout (default: the `main` worktree)
|
|
@@ -20,13 +20,13 @@ type Config = {
|
|
|
20
20
|
// RETIRED (residence compat): the old private-overlay toggle — ignored with the same loud notice;
|
|
21
21
|
// its data-untrack semantics are long gone. See `spex guide footprint` MIGRATIONS.
|
|
22
22
|
private?: boolean
|
|
23
|
-
// which harness targets `spex materialize` delivers into — native
|
|
24
|
-
// bundle; resolved + validated by [[harness-select]] (harness-select.ts).
|
|
23
|
+
// which harness targets `spex materialize` delivers into — a native HarnessId or a {plugin:"<folder>"}
|
|
24
|
+
// bundle; resolved + validated by [[harness-select]] (harness-select.ts). REQUIRED — no default set; `spex init --harness` stamps it.
|
|
25
25
|
harnesses?: (string | { plugin?: string })[]
|
|
26
26
|
dashboard?: {
|
|
27
27
|
apiUrl?: string // the per-project backend the board proxies to (read frontend-side; see api-endpoint)
|
|
28
28
|
title?: string // override for the browser-tab name (default: the repo-root basename; see tab-title)
|
|
29
|
-
icon?: string //
|
|
29
|
+
icon?: string // project identity icon: a picker preset id; existing emoji/Iconify/URL values remain supported ([[identity-config]])
|
|
30
30
|
}
|
|
31
31
|
sessions?: {
|
|
32
32
|
maxActive?: number // concurrency cap: max agents AUTONOMOUSLY PROGRESSING at once (default 8; see sessions.ts maxActive)
|
|
@@ -34,7 +34,7 @@ type Config = {
|
|
|
34
34
|
// its harness AND its exact launch command; the chosen NAME is persisted on the record so resume reuses the
|
|
35
35
|
// same auth. `harness` defaults to 'claude'. Host-specific `cmd`s (abs wrapper paths) belong in the
|
|
36
36
|
// gitignored spexcode.local.json — the name is portable, the cmd is a machine fact.
|
|
37
|
-
launchers?: { [name: string]: { harness?:
|
|
37
|
+
launchers?: { [name: string]: { harness?: HarnessId; cmd: string } }
|
|
38
38
|
defaultLauncher?: string // the launcher a create with no explicit --launcher/dropdown pick uses; required for no-choice creates
|
|
39
39
|
}
|
|
40
40
|
serve?: {
|
package/spec-cli/src/lint.ts
CHANGED
|
@@ -1,145 +1,92 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync, existsSync, statSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { repoRoot, git, driftIndex, historyIndex, rowsFor } from './git.js'
|
|
4
4
|
import { loadSpecs } from './specs.js'
|
|
5
5
|
import { readJsonConfig } from './layout.js'
|
|
6
6
|
import { extractors, extractorFor, extOf, resolveAnchor, windowCommits, anchorHitCommits } from './anchors.js'
|
|
7
|
+
import { DEFAULT_TEST_GLOBS, sourcePolicyDescription, trackedSourceFiles } from './source-files.js'
|
|
7
8
|
|
|
8
9
|
export type Finding = { level: 'error' | 'warn'; rule: string; spec?: string; file?: string; msg: string }
|
|
9
10
|
|
|
10
11
|
export type LintConfig = {
|
|
11
|
-
governedRoots: string[] // dirs whose source files must each be governed by a spec
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
maxChildren: number // breadth budget: warn at >= this many direct children
|
|
12
|
+
governedRoots: string[] // dirs whose tracked source files must each be governed by a spec. '.' = whole project.
|
|
13
|
+
sourceIncludeGlobs: string[] | null // null includes every tracked regular text file; [] intentionally includes none
|
|
14
|
+
sourceExcludeGlobs: string[] // explicit source-policy subtraction
|
|
15
|
+
sourceExtensions: string[] | null // compatibility shorthand compiled into sourceIncludeGlobs
|
|
16
|
+
testGlobs: string[] // globs EXCLUDED from coverage; set [] to govern tests too
|
|
17
17
|
maxOwners: number // warn when a file is governed (code:) by > this many nodes
|
|
18
18
|
scenarioTags: string[] // the closed vocabulary an eval scenario's `tags:` must draw from; extend it to mint a new tag
|
|
19
|
+
scopedCodeMiss: 'warn' | 'ignore' // the file-level drift ADVISORY on a selector-scoped code: file whose window has no
|
|
20
|
+
// selector hit ([[code-anchor]]). 'warn' (default) keeps today's drift warning; 'ignore'
|
|
21
|
+
// silences ONLY that advisory — hit blocks, bare code drift, integrity, acks, related
|
|
22
|
+
// semantics, and eval freshness are all untouched by this knob.
|
|
19
23
|
}
|
|
20
24
|
const DEFAULT_CONFIG: LintConfig = {
|
|
21
25
|
governedRoots: ['spec-dashboard/src', 'spec-cli/src'],
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
maxChildren: 8,
|
|
26
|
+
sourceIncludeGlobs: null,
|
|
27
|
+
sourceExcludeGlobs: [],
|
|
28
|
+
sourceExtensions: null,
|
|
29
|
+
testGlobs: DEFAULT_TEST_GLOBS,
|
|
27
30
|
maxOwners: 3,
|
|
28
31
|
scenarioTags: ['frontend-e2e', 'backend-api', 'cli', 'desktop', 'mobile'],
|
|
32
|
+
scopedCodeMiss: 'warn',
|
|
29
33
|
}
|
|
30
34
|
export function loadConfig(root: string): LintConfig {
|
|
31
35
|
// Absent spexcode.json → tuned defaults; a MALFORMED one throws LOUD (readJsonConfig) rather than
|
|
32
36
|
// silently reverting the author's budgets to defaults and green-washing the very warnings they tuned.
|
|
33
37
|
const c = readJsonConfig(join(root, 'spexcode.json'))?.lint ?? {}
|
|
34
|
-
const merged = { ...DEFAULT_CONFIG, ...c
|
|
38
|
+
const merged = { ...DEFAULT_CONFIG, ...c }
|
|
35
39
|
return normalizeConfig(merged)
|
|
36
40
|
}
|
|
37
41
|
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// adopter makes reading the prose, so we accept-what-they-meant rather than reject:
|
|
41
|
-
// - a LEADING DOT on an extension: the matcher is `\.(ext)$`, so a literal ".ts" becomes `\..ts$` and never
|
|
42
|
-
// matches. Strip leading dots → ["ts"] and [".ts"] both work (prose historically showed ".ts").
|
|
43
|
-
// - a testGlob with NO "/": globs anchor to the full repo-relative path, so a bare "*.test.ts" matches only
|
|
44
|
-
// ROOT-level files and leaks every nested test into coverage. A slash-less glob is a basename intent →
|
|
45
|
-
// prepend "**/" so it matches that basename at any depth (the default "**/*.test.*" already does).
|
|
42
|
+
// Compile every author-facing source selector into one include-minus-exclude/test policy. In particular,
|
|
43
|
+
// sourceExtensions is compatibility syntax only: it contributes include globs and never reaches discovery.
|
|
46
44
|
export function normalizeConfig(cfg: LintConfig): LintConfig {
|
|
45
|
+
// a mistyped enum silently reverting to the default would green-wash (or over-warn) exactly the
|
|
46
|
+
// advisory the author meant to tune — same fail-loud rule as a malformed spexcode.json.
|
|
47
|
+
if (cfg.scopedCodeMiss !== 'warn' && cfg.scopedCodeMiss !== 'ignore')
|
|
48
|
+
throw new Error(`spexcode.json lint.scopedCodeMiss must be "warn" or "ignore", got ${JSON.stringify(cfg.scopedCodeMiss)}`)
|
|
47
49
|
const dedot = (xs: string[]) => xs.map((x) => x.replace(/^\.+/, ''))
|
|
50
|
+
const anyDepth = (xs: string[]) => xs.map((g) => (g.includes('/') ? g : `**/${g}`))
|
|
51
|
+
const extensions = cfg.sourceExtensions === null ? null : dedot(cfg.sourceExtensions)
|
|
52
|
+
const includes = cfg.sourceIncludeGlobs === null && extensions === null
|
|
53
|
+
? null
|
|
54
|
+
: [...new Set([...anyDepth(cfg.sourceIncludeGlobs ?? []), ...(extensions ?? []).map((ext) => `**/*.${ext}`)])]
|
|
48
55
|
return {
|
|
49
56
|
...cfg,
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
sourceIncludeGlobs: includes,
|
|
58
|
+
sourceExcludeGlobs: anyDepth(cfg.sourceExcludeGlobs),
|
|
59
|
+
sourceExtensions: extensions,
|
|
60
|
+
testGlobs: anyDepth(cfg.testGlobs),
|
|
53
61
|
}
|
|
54
62
|
}
|
|
55
63
|
|
|
56
|
-
// the source-file matcher, built from the configurable `sourceExtensions` knob. Coverage uses it to decide
|
|
57
|
-
// which tracked files must be governed; eval lint's `eval-coverage` reuses THE SAME knob so ONE setting
|
|
58
|
-
// defines "source" for both coverage axes — a non-web project (Rust/Go/Python .rs/.go/.py) sets it once and
|
|
59
|
-
// both the coverage warning and the loss-signal blind-spot check follow, with no second web-only allowlist.
|
|
60
|
-
export const sourceExtRe = (extensions: string[]) => new RegExp(`\\.(${extensions.join('|')})$`)
|
|
61
|
-
|
|
62
|
-
// a minimal glob → RegExp anchored to the full repo-relative path: `**` = any dirs, `*` = within a segment.
|
|
63
|
-
function globToRe(glob: string): RegExp {
|
|
64
|
-
const body = glob.split(/(\*\*\/|\*\*|\*|\?)/).map((seg) => {
|
|
65
|
-
if (seg === '**/') return '(?:.*/)?'
|
|
66
|
-
if (seg === '**') return '.*'
|
|
67
|
-
if (seg === '*') return '[^/]*'
|
|
68
|
-
if (seg === '?') return '[^/]'
|
|
69
|
-
return seg.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
70
|
-
}).join('')
|
|
71
|
-
return new RegExp(`^${body}$`)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// coverage enumerates source via GIT-TRACKED files (`git ls-files`, through git() which strips the hook's
|
|
75
|
-
// GIT_DIR), NOT a raw fs walk. Tracked-only auto-excludes node_modules + build output (gitignored), nested
|
|
76
|
-
// or linked worktrees + submodules (a separate index), `.git`, and anything untracked — so governedRoots
|
|
77
|
-
// '.' means "all tracked source" with no fs explosion and no hand-maintained skip list (git IS the database).
|
|
78
|
-
// Test files drop per cfg.testGlobs (default *.test.*; set [] to govern tests too).
|
|
79
|
-
function trackedSourceFiles(root: string, roots: string[], src: RegExp, testGlobs: string[]): string[] {
|
|
80
|
-
const testRes = testGlobs.map(globToRe)
|
|
81
|
-
const out = new Set<string>()
|
|
82
|
-
for (const r of roots) {
|
|
83
|
-
let listed = ''
|
|
84
|
-
try { listed = git(['-C', root, 'ls-files', '-z', '--', r]) } catch { continue }
|
|
85
|
-
for (const f of listed.split('\0')) {
|
|
86
|
-
if (!f || !src.test(f) || testRes.some((re) => re.test(f))) continue
|
|
87
|
-
out.add(f)
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return [...out]
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// code-identifier signals: camelCase | snake_case | foo( | `backticked` | /a/path.ext | bare file.ext. Only
|
|
94
|
-
// the bare-filename branch needs the extension allowlist (config, so a non-TS project recognises its own
|
|
95
|
-
// sources) — without it a bare `word.word` would match ordinary prose like "e.g".
|
|
96
|
-
function identRe(extensions: string[]): RegExp {
|
|
97
|
-
const ext = extensions.join('|')
|
|
98
|
-
return new RegExp(`[a-z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*|\\b[a-z]+_[a-z0-9_]+\\b|\\b\\w+\\(|\`[^\`]+\`|\\/[\\w./-]+\\.\\w+|\\b[\\w-]+\\.(${ext})\\b`, 'g')
|
|
99
|
-
}
|
|
100
|
-
// step-by-step how-to phrasing: numbered steps, or sequencing connectives that walk through mechanics.
|
|
101
|
-
const STEP_LINE = /^\s*(\d+[.)]\s|[-*]\s*(first|then|next|finally)\b)|(^|[,;]\s*)(first|then|next|finally),/i
|
|
102
|
-
// returns a one-line reason naming whichever low-altitude proxy(ies) tripped (length / identifier density /
|
|
103
|
-
// step-by-step), or null when the body is at altitude.
|
|
104
|
-
function altitude(body: string, cfg: LintConfig, ident: RegExp): string | null {
|
|
105
|
-
const a = cfg.altitude
|
|
106
|
-
const lines = body.split('\n')
|
|
107
|
-
const nb = lines.filter((l) => l.trim()).length
|
|
108
|
-
const chars = body.length
|
|
109
|
-
// identifiers and step phrasing are read from PROSE only — a fenced code sample is acknowledged code,
|
|
110
|
-
// not low-altitude narration, so it inflates length but not density.
|
|
111
|
-
let inFence = false, signals = 0, steps = 0
|
|
112
|
-
for (const l of lines) {
|
|
113
|
-
if (/^\s*```/.test(l)) { inFence = !inFence; continue }
|
|
114
|
-
if (inFence || !l.trim()) continue
|
|
115
|
-
signals += l.match(ident)?.length ?? 0
|
|
116
|
-
if (STEP_LINE.test(l)) steps++
|
|
117
|
-
}
|
|
118
|
-
const density = signals / Math.max(1, nb)
|
|
119
|
-
const why: string[] = []
|
|
120
|
-
if (nb > a.lineBudget || chars > a.charBudget) why.push(`${nb} non-blank lines / ${chars} chars over budget (${a.lineBudget}/${a.charBudget})`)
|
|
121
|
-
if (nb > a.sizeable && density > a.dense) why.push(`code-identifier density ${density.toFixed(2)}/line over ${a.dense}`)
|
|
122
|
-
if (nb > a.sizeable && steps >= a.steps) why.push(`${steps} step-by-step how-to lines`)
|
|
123
|
-
return why.length ? why.join('; ') : null
|
|
124
|
-
}
|
|
125
|
-
|
|
126
64
|
export async function specLint(): Promise<Finding[]> {
|
|
127
65
|
const root = repoRoot()
|
|
128
66
|
const cfg = loadConfig(root)
|
|
129
|
-
const
|
|
130
|
-
const srcRe = sourceExtRe(cfg.sourceExtensions)
|
|
67
|
+
const governed = trackedSourceFiles(root, cfg.governedRoots, cfg)
|
|
131
68
|
const specs = await loadSpecs()
|
|
132
69
|
const out: Finding[] = []
|
|
133
70
|
|
|
134
|
-
// integrity + build the file -> owners map.
|
|
71
|
+
// integrity + build the file -> owners map. A relation's STRUCTURAL problems (a duplicate entry,
|
|
72
|
+
// bare/scoped mixing on one base path, a selector on a glob — all from parseRelation,
|
|
73
|
+
// [[code-anchor]]) are integrity errors: malformed edges block like broken ones.
|
|
135
74
|
const owners = new Map<string, string[]>()
|
|
75
|
+
const claimed = new Set<string>()
|
|
136
76
|
for (const s of specs) {
|
|
77
|
+
for (const p of s.relationProblems)
|
|
78
|
+
out.push({ level: 'error', rule: 'integrity', spec: s.id, msg: `'${s.id}' ${p}` })
|
|
79
|
+
const scopedPaths = new Set(s.codeScoped.map((e) => e.path))
|
|
137
80
|
for (const f of s.code) {
|
|
138
81
|
if (!existsSync(join(root, f)))
|
|
139
82
|
out.push({ level: 'error', rule: 'integrity', spec: s.id, file: f, msg: `spec '${s.id}' lists a missing file: ${f}` })
|
|
140
|
-
|
|
83
|
+
claimed.add(f)
|
|
84
|
+
// a selector-SCOPED entry claims named units, not the whole file, so it stays out of the owners
|
|
85
|
+
// bound below ([[code-anchor]]) — `spex spec owner` still displays it as a (scoped) governor.
|
|
86
|
+
if (!scopedPaths.has(f)) owners.set(f, [...(owners.get(f) ?? []), s.id])
|
|
141
87
|
}
|
|
142
|
-
// one-govern: a node is source of truth for at most ONE file
|
|
88
|
+
// one-govern: a node is source of truth for at most ONE file — DISTINCT base paths; several
|
|
89
|
+
// `path#symbol` selectors on the same file are one subject — so drift/eval/ack have a single
|
|
143
90
|
// unambiguous subject (see [[governed-related]]). >1 is a defect — pick the true subject, demote the
|
|
144
91
|
// rest to related. ERROR (the node-side twin of too-many-owners' file-side bound). 0 is fine.
|
|
145
92
|
if (s.code.length > 1)
|
|
@@ -148,7 +95,6 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
148
95
|
// a file is COVERED if any node GOVERNS (code:) or merely REFERENCES (related:) it; integrity covers both.
|
|
149
96
|
// `related:` is the coverage net: govern is a sharp ideally-one-file pointer, so most files are reached by
|
|
150
97
|
// related, not govern (see [[governed-related]]). It carries coverage but never drift, never eval freshness.
|
|
151
|
-
const claimed = new Set<string>(owners.keys())
|
|
152
98
|
for (const s of specs) for (const f of s.related) {
|
|
153
99
|
if (!existsSync(join(root, f)))
|
|
154
100
|
out.push({ level: 'error', rule: 'integrity', spec: s.id, file: f, msg: `spec '${s.id}' lists a missing related file: ${f}` })
|
|
@@ -234,33 +180,9 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
234
180
|
}
|
|
235
181
|
}
|
|
236
182
|
|
|
237
|
-
// altitude: a body that re-narrates mechanics instead of stating contract/intent (WARN — soft budget).
|
|
238
|
-
for (const s of specs) {
|
|
239
|
-
const why = altitude(s.body, cfg, ident)
|
|
240
|
-
if (why) out.push({ level: 'warn', rule: 'altitude', spec: s.id, msg: `'${s.id}' body reads low-altitude (mechanics, not contract): ${why}` })
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// breadth: a node with too many DIRECT children is altitude's structural twin — splitting a node to pass
|
|
244
|
-
// altitude shouldn't just relocate the sprawl into a wide flat fan-out (WARN — soft, advisory). Children
|
|
245
|
-
// are derived from the parent links loadSpecs already computes; no explicit child array to keep in sync.
|
|
246
|
-
const childCount = new Map<string, number>()
|
|
247
|
-
for (const s of specs) if (s.parent) childCount.set(s.parent, (childCount.get(s.parent) ?? 0) + 1)
|
|
248
|
-
for (const s of specs) {
|
|
249
|
-
const n = childCount.get(s.id) ?? 0
|
|
250
|
-
if (n >= cfg.maxChildren)
|
|
251
|
-
out.push({ level: 'warn', rule: 'breadth', spec: s.id, msg: `'${s.id}' has ${n} direct child nodes (>= ${cfg.maxChildren}) — is an intermediate grouping layer missing? (a flat list of genuine peers is sometimes right — ignore if so)` })
|
|
252
|
-
}
|
|
253
|
-
|
|
254
183
|
// coverage: every governed source file must be claimed by at least one spec.
|
|
255
|
-
const governed = trackedSourceFiles(root, cfg.governedRoots, srcRe, cfg.testGlobs)
|
|
256
|
-
// no governed source found at all → make it a SELF-EXPLANATORY repair entrypoint, not a dead end. The two
|
|
257
|
-
// knobs governing this are BOTH web-tuned by default (extensions ts/tsx/js/jsx; roots this repo's own dirs),
|
|
258
|
-
// so a non-web adopter (Rust/Go/Python) hits zero source two ways: right dir but wrong extension, or an
|
|
259
|
-
// unset root. Naming BOTH knobs, echoing their CURRENT values (so the mismatch is visible — "searching .ts
|
|
260
|
-
// in a .py tree"), and stating the `lint`-key nesting (a top-level key silently no-ops) turns the warning
|
|
261
|
-
// into the fix. Concrete non-web extension examples so the repair is copy-pasteable, not a schema hunt.
|
|
262
184
|
if (governed.length === 0)
|
|
263
|
-
out.push({ level: 'warn', rule: 'coverage', msg: `governing NOTHING — 0 source
|
|
185
|
+
out.push({ level: 'warn', rule: 'coverage', msg: `governing NOTHING — 0 source candidates under governedRoots [${cfg.governedRoots.join(', ')}]; ${sourcePolicyDescription(cfg)}. Repair these knobs under the "lint" key in spexcode.json (top-level keys are ignored): governedRoots, sourceIncludeGlobs, sourceExcludeGlobs, testGlobs; sourceExtensions remains compatibility shorthand for include globs.` })
|
|
264
186
|
for (const f of governed)
|
|
265
187
|
if (!claimed.has(f)) out.push({ level: 'warn', rule: 'coverage', file: f, msg: `no spec governs: ${f}` })
|
|
266
188
|
|
|
@@ -276,55 +198,73 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
276
198
|
out.push({ level: 'warn', rule: 'owners', msg: `${over.length} file(s) are governed by > ${cfg.maxOwners} nodes — each holds more separately-specified functionality than one file should. Worst: ${top}. SPLIT the file so each governor owns its own module (or merge the nodes, or give it a single foundation owner + related:).` })
|
|
277
199
|
}
|
|
278
200
|
|
|
279
|
-
// code anchors ([[code-anchor]]): a code: entry may pin
|
|
280
|
-
// the BLOCKING tier of drift: a window
|
|
281
|
-
// governed file) whose --unified=0 hunks
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
201
|
+
// code anchors ([[code-anchor]]): a code:/related: entry may pin named units (`path#symbol` — any
|
|
202
|
+
// number per base file, OR'd). On code:, the anchor is the BLOCKING tier of drift: a window
|
|
203
|
+
// commit (spec's last version..HEAD, non-merge, touching the governed file) whose --unified=0 hunks
|
|
204
|
+
// intersect any pinned unit's line range — extracted from the file AS OF that commit, by the
|
|
205
|
+
// extension's ONE designated extractor — is ONE anchor-drift ERROR naming the hit selectors, unless a
|
|
206
|
+
// Spec-OK ack covers it. On related:, the SAME engine yields only a soft warn on a hit — a scoped
|
|
207
|
+
// related miss is silent (never blocks, no ack, no eval freshness). Resolution failures are never
|
|
208
|
+
// silent for either relation: a dead or ambiguous selector, a selector on a directory, an unparseable
|
|
209
|
+
// working-tree file, an extension with no designated extractor, and a designated extractor that can't
|
|
210
|
+
// run here (no host typescript) all ERROR with the repair spelled out.
|
|
286
211
|
const regs = extractors(root)
|
|
287
212
|
const [didx, hidx] = await Promise.all([driftIndex(root), historyIndex(root)])
|
|
288
213
|
const readyWarned = new Set<string>()
|
|
289
214
|
for (const s of specs) {
|
|
290
|
-
for (const {
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
215
|
+
for (const { relation, entries } of [{ relation: 'code' as const, entries: s.codeScoped }, { relation: 'related' as const, entries: s.relatedScoped }]) {
|
|
216
|
+
for (const { path, selectors } of entries) {
|
|
217
|
+
const x = extractorFor(regs, extOf(path))
|
|
218
|
+
if (!x) {
|
|
219
|
+
out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `'${s.id}' anchors ${path}#${selectors.join(', #')} (${relation}:), but no extractor is designated for '.${extOf(path)}' files — this language has no anchor support yet: add a LangSpec row (anchors.ts) or drop the selector(s)` })
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
const ready = x.ready()
|
|
223
|
+
if (ready !== true) {
|
|
224
|
+
// once per (extractor, reason), even across several anchored nodes — one repair, one message.
|
|
225
|
+
if (!readyWarned.has(x.id + ready)) { readyWarned.add(x.id + ready); out.push({ level: 'error', rule: 'integrity', msg: `anchor extractor '${x.id}' cannot run: ${ready}` }) }
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
if (!existsSync(join(root, path))) continue // the missing FILE already errored above
|
|
229
|
+
if (statSync(join(root, path)).isDirectory()) {
|
|
230
|
+
out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `'${s.id}' puts a selector on a directory (${relation}: ${path}#${selectors[0]}) — a selector scopes ONE real file` })
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
let units
|
|
234
|
+
try { units = x.extract(readFileSync(join(root, path), 'utf8'), path) } catch (e: any) {
|
|
235
|
+
out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `anchor ${path}#${selectors.join(', #')} ('${s.id}') is unverifiable — the current file does not parse: ${e?.message ?? e}` })
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
// each selector resolves (or errors) on its own; only the live ones feed the window engine.
|
|
239
|
+
const live: string[] = []
|
|
240
|
+
for (const sym of selectors) {
|
|
241
|
+
const res = resolveAnchor(units, sym)
|
|
242
|
+
if ('dead' in res) {
|
|
243
|
+
out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `dead anchor: ${path}#${sym} ('${s.id}') names no unit on the current tree — the unit was deleted or renamed; update the spec's ${relation}: entry to follow it` })
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
if ('ambiguous' in res) {
|
|
247
|
+
out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `ambiguous anchor: ${path}#${sym} ('${s.id}') names ${res.ambiguous} same-named units in one file — an anchor must be unique; rename one unit` })
|
|
248
|
+
continue
|
|
249
|
+
}
|
|
250
|
+
if (res.ok.typeOnly)
|
|
251
|
+
out.push({ level: 'warn', rule: 'anchor', spec: s.id, file: path, msg: `${path}#${sym} anchors a ${res.ok.kind} — anchoring a type is usually wrong (types reshape with every refactor); anchor the behaviour-bearing unit instead` })
|
|
252
|
+
live.push(sym)
|
|
253
|
+
}
|
|
254
|
+
if (!live.length) continue
|
|
255
|
+
const since = rowsFor(hidx, s.path)[0]?.hash || ''
|
|
256
|
+
const win = windowCommits(didx, since, path)
|
|
257
|
+
if (!win.length) continue
|
|
258
|
+
const hits = await anchorHitCommits(root, win, path, live, x)
|
|
259
|
+
if (!hits.length) continue
|
|
260
|
+
const hitSyms = [...new Set(hits.flatMap((h) => h.selectors))]
|
|
325
261
|
const shas = hits.map((h) => h.commit.slice(0, 8)).join(', ')
|
|
262
|
+
const unparseable = hits.filter((h) => h.unparseable)
|
|
326
263
|
const parseNote = unparseable.length ? ` (${unparseable.length} of these could not be parsed at that commit — counted as hits conservatively)` : ''
|
|
327
|
-
|
|
264
|
+
if (relation === 'code')
|
|
265
|
+
out.push({ level: 'error', rule: 'anchor-drift', spec: s.id, file: path, msg: `${path}#${hitSyms.join(', #')} was changed by ${hits.length} commit(s) since spec '${s.id}' v${s.version} [${shas}]${parseNote} — the anchored contract's code moved: update the spec, or 'spex spec ack ${s.id} --reason "…"' if the contract still holds` })
|
|
266
|
+
else
|
|
267
|
+
out.push({ level: 'warn', rule: 'related-drift', spec: s.id, file: path, msg: `related ${path}#${hitSyms.join(', #')} ('${s.id}') was changed by ${hits.length} commit(s) since v${s.version} [${shas}]${parseNote} — a scoped dependency shifted, worth a glance (SOFT: never blocks, no ack, no eval staleness)` })
|
|
328
268
|
}
|
|
329
269
|
}
|
|
330
270
|
}
|
|
@@ -334,9 +274,15 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
334
274
|
// commit to the file counts iff it is NOT reachable from the spec's latest version (in-memory
|
|
335
275
|
// parent-edge reachability, the equivalent of `rev-list <version>..HEAD -- <file>`), never a
|
|
336
276
|
// log-position or timestamp guess.
|
|
277
|
+
// A selector-SCOPED code file keeps this file-level advisory by default (a miss still nudges); the
|
|
278
|
+
// committed `lint.scopedCodeMiss: "ignore"` silences ONLY this advisory for scoped entries — the
|
|
279
|
+
// anchor engine's verdicts above (hit = block, resolution failures = integrity) are untouched.
|
|
337
280
|
for (const s of specs) {
|
|
338
|
-
|
|
281
|
+
const scopedPaths = new Set(s.codeScoped.map((e) => e.path))
|
|
282
|
+
for (const d of s.driftFiles) {
|
|
283
|
+
if (cfg.scopedCodeMiss === 'ignore' && scopedPaths.has(d.file)) continue
|
|
339
284
|
out.push({ level: 'warn', rule: 'drift', spec: s.id, file: d.file, msg: `${d.file} is ${d.behind} commit(s) ahead of spec '${s.id}' (v${s.version}) — may be stale` })
|
|
285
|
+
}
|
|
340
286
|
}
|
|
341
287
|
|
|
342
288
|
// related drift: the SOFT tier ([[governed-related]]). A referenced file moved ahead of the node's
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
// @@@ login page - the gateway's gate is a DESIGNED page, not the browser's Basic-auth dialog (which can't
|
|
2
2
|
// be styled and feels like a 1998 intranet). Self-contained: inline CSS + SVG, zero external assets, so it
|
|
3
3
|
// renders before anything is authorised. Dark, calm, a single password field; an error state when the
|
|
4
|
-
// password is wrong. The form POSTs to
|
|
5
|
-
|
|
4
|
+
// password is wrong. The form POSTs to `action` (same-origin), which mints the auth cookie and redirects.
|
|
5
|
+
// One page serves every gate: the single-project gateway's /login (the defaults) and the multi-project
|
|
6
|
+
// hub's admin + per-project logins ([[gateway-hub]] passes action/heading/sub). Every option is
|
|
7
|
+
// HTML-escaped at this sink — projectIds are path-derived, attacker-influencable strings.
|
|
8
|
+
export function loginPage(error = false, opts: { action?: string; heading?: string; sub?: string } = {}): string {
|
|
9
|
+
const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!))
|
|
10
|
+
const action = esc(opts.action ?? '/login')
|
|
11
|
+
const heading = esc(opts.heading ?? 'Restricted access')
|
|
12
|
+
const sub = esc(opts.sub ?? 'This is a private agent workspace. Enter the access password to continue.')
|
|
6
13
|
return `<!doctype html>
|
|
7
14
|
<html lang="en">
|
|
8
15
|
<head>
|
|
@@ -58,7 +65,7 @@ export function loginPage(error = false): string {
|
|
|
58
65
|
</style>
|
|
59
66
|
</head>
|
|
60
67
|
<body>
|
|
61
|
-
<form class="card" method="POST" action="
|
|
68
|
+
<form class="card" method="POST" action="${action}" autocomplete="off">
|
|
62
69
|
<div class="mark">
|
|
63
70
|
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
64
71
|
<rect x="2" y="2" width="28" height="28" rx="8" fill="#0e1626" stroke="#3f6fe0" stroke-opacity="0.5"/>
|
|
@@ -66,8 +73,8 @@ export function loginPage(error = false): string {
|
|
|
66
73
|
</svg>
|
|
67
74
|
<b>Spex<span>Code</span></b>
|
|
68
75
|
</div>
|
|
69
|
-
<h1
|
|
70
|
-
<p class="sub"
|
|
76
|
+
<h1>${heading}</h1>
|
|
77
|
+
<p class="sub">${sub}</p>
|
|
71
78
|
${error ? '<div class="err">Incorrect password — try again.</div>' : ''}
|
|
72
79
|
<label for="password">Password</label>
|
|
73
80
|
<input id="password" name="password" type="password" autofocus required placeholder="••••••••••">
|