spexcode 0.1.4 → 0.1.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spexcode",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "SpexCode — a spec-driven, self-developing dev tool. The `spex` CLI + spec server reads the .spec tree and its git history, and serves the dashboard.",
6
6
  "license": "MIT",
@@ -99,8 +99,8 @@ function cookieOf(header: string | undefined, name: string): string | null {
99
99
  }
100
100
  return null
101
101
  }
102
- function isAuthed(req: http.IncomingMessage, token: string): boolean {
103
- const c = cookieOf(req.headers.cookie, COOKIE)
102
+ function isAuthed(req: http.IncomingMessage, token: string, cookieName: string): boolean {
103
+ const c = cookieOf(req.headers.cookie, cookieName)
104
104
  return c != null && constEq(c, token)
105
105
  }
106
106
 
@@ -124,7 +124,12 @@ export function startGateway(opts: GatewayOpts): void {
124
124
  const gated = !!opts.password
125
125
  const token = gated ? authToken(opts.password) : ''
126
126
  const secure = !!opts.tls
127
- const setCookie = `${COOKIE}=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=2592000${secure ? '; Secure' : ''}`
127
+ // the auth cookie is HOST-scoped (RFC 6265 ignores the port), so two gateways on one IP would share a
128
+ // single 'spex_auth' jar entry and clobber each other's login. Key the name by the public port — the
129
+ // unique discriminator on a host, exactly what the user's two URLs differ by — so same-host instances
130
+ // (e.g. :8787 and :8788) stay logged in concurrently and a logout clears only its own.
131
+ const cookieName = `${COOKIE}_${opts.publicPort}`
132
+ const setCookie = `${cookieName}=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=2592000${secure ? '; Secure' : ''}`
128
133
 
129
134
  const handler = (req: http.IncomingMessage, res: http.ServerResponse) => {
130
135
  const url = (req.url || '/').split('?')[0]
@@ -132,8 +137,8 @@ export function startGateway(opts: GatewayOpts): void {
132
137
  // login surface — the only routes reachable without a cookie. Absent entirely when ungated.
133
138
  if (url === '/login' && req.method === 'POST') return doLogin(req, res, opts.password, setCookie)
134
139
  if (url === '/login') return sendHtml(res, 200, loginPage())
135
- if (url === '/logout') { res.writeHead(302, { 'Set-Cookie': `${COOKIE}=; Path=/; Max-Age=0`, Location: '/login' }); return res.end() }
136
- if (!isAuthed(req, token)) {
140
+ if (url === '/logout') { res.writeHead(302, { 'Set-Cookie': `${cookieName}=; Path=/; Max-Age=0`, Location: '/login' }); return res.end() }
141
+ if (!isAuthed(req, token, cookieName)) {
137
142
  if (url.startsWith('/api')) { res.writeHead(401, { 'Content-Type': 'application/json' }); return res.end('{"error":"authentication required"}') }
138
143
  res.writeHead(302, { Location: '/login' }); return res.end()
139
144
  }
@@ -148,7 +153,7 @@ export function startGateway(opts: GatewayOpts): void {
148
153
  // it on the same-origin handshake), then raw-pipe to the loopback supervisor, replaying the buffered
149
154
  // upgrade request so the child completes the WebSocket handshake. Mirrors supervise.ts's byte pipe.
150
155
  server.on('upgrade', (req, socket, head) => {
151
- if (gated && !isAuthed(req, token)) { socket.destroy(); return }
156
+ if (gated && !isAuthed(req, token, cookieName)) { socket.destroy(); return }
152
157
  const up = net.connect(opts.upstreamPort, '127.0.0.1', () => {
153
158
  up.write(`${req.method} ${req.url} HTTP/1.1\r\n` + rawHeaders(req))
154
159
  if (head && head.length) up.write(head)
@@ -1,13 +1,14 @@
1
1
  import { readdirSync, readFileSync, existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import { repoRoot, stagedFiles } from './git.js'
3
+ import { repoRoot, stagedFiles, git } from './git.js'
4
4
  import { loadSpecs } from './specs.js'
5
5
 
6
6
  export type Finding = { level: 'error' | 'warn'; rule: string; spec?: string; file?: string; msg: string }
7
7
 
8
8
  export type LintConfig = {
9
- governedRoots: string[] // dirs whose source files must each be governed by a spec (coverage)
9
+ governedRoots: string[] // dirs whose source files must each be governed by a spec (coverage). '.' = whole project (safe: only git-TRACKED files, so node_modules/build/nested worktrees never count).
10
10
  sourceExtensions: string[] // extensions coverage treats as source files
11
+ testGlobs: string[] // globs EXCLUDED from coverage — tests aren't governed product (default ['**/*.test.*']; set [] to govern tests too)
11
12
  identifierExtensions: string[]// extensions the altitude bare-filename signal recognises (see IDENT below)
12
13
  altitude: { lineBudget: number; charBudget: number; sizeable: number; dense: number; steps: number }
13
14
  maxChildren: number // breadth budget: warn at >= this many direct children
@@ -17,6 +18,7 @@ export type LintConfig = {
17
18
  const DEFAULT_CONFIG: LintConfig = {
18
19
  governedRoots: ['spec-dashboard/src', 'spec-cli/src'],
19
20
  sourceExtensions: ['ts', 'tsx', 'js', 'jsx'],
21
+ testGlobs: ['**/*.test.*'],
20
22
  identifierExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'md'],
21
23
  altitude: { lineBudget: 50, charBudget: 4200, sizeable: 35, dense: 1.3, steps: 3 },
22
24
  maxChildren: 8,
@@ -33,15 +35,35 @@ export function loadConfig(root: string): LintConfig {
33
35
  }
34
36
  }
35
37
 
36
- const SKIP_DIRS = new Set(['node_modules', 'dist', '.vite'])
38
+ // a minimal glob → RegExp anchored to the full repo-relative path: `**` = any dirs, `*` = within a segment.
39
+ function globToRe(glob: string): RegExp {
40
+ const body = glob.split(/(\*\*\/|\*\*|\*|\?)/).map((seg) => {
41
+ if (seg === '**/') return '(?:.*/)?'
42
+ if (seg === '**') return '.*'
43
+ if (seg === '*') return '[^/]*'
44
+ if (seg === '?') return '[^/]'
45
+ return seg.replace(/[.+^${}()|[\]\\]/g, '\\$&')
46
+ }).join('')
47
+ return new RegExp(`^${body}$`)
48
+ }
37
49
 
38
- function sourceFiles(root: string, rel: string, acc: string[], src: RegExp) {
39
- const abs = join(root, rel)
40
- if (!existsSync(abs)) return
41
- for (const e of readdirSync(abs, { withFileTypes: true })) {
42
- if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) sourceFiles(root, join(rel, e.name), acc, src) }
43
- else if (src.test(e.name)) acc.push(join(rel, e.name))
50
+ // coverage enumerates source via GIT-TRACKED files (`git ls-files`, through git() which strips the hook's
51
+ // GIT_DIR), NOT a raw fs walk. Tracked-only auto-excludes node_modules + build output (gitignored), nested
52
+ // or linked worktrees + submodules (a separate index), `.git`, and anything untracked — so governedRoots
53
+ // '.' means "all tracked source" with no fs explosion and no hand-maintained skip list (git IS the database).
54
+ // Test files drop per cfg.testGlobs (default *.test.*; set [] to govern tests too).
55
+ function trackedSourceFiles(root: string, roots: string[], src: RegExp, testGlobs: string[]): string[] {
56
+ const testRes = testGlobs.map(globToRe)
57
+ const out = new Set<string>()
58
+ for (const r of roots) {
59
+ let listed = ''
60
+ try { listed = git(['-C', root, 'ls-files', '-z', '--', r]) } catch { continue }
61
+ for (const f of listed.split('\0')) {
62
+ if (!f || !src.test(f) || testRes.some((re) => re.test(f))) continue
63
+ out.add(f)
64
+ }
44
65
  }
66
+ return [...out]
45
67
  }
46
68
 
47
69
  // code-identifier signals: camelCase | snake_case | foo( | `backticked` | /a/path.ext | bare file.ext. Only
@@ -136,8 +158,7 @@ export async function specLint(): Promise<Finding[]> {
136
158
  }
137
159
 
138
160
  // coverage: every governed source file must be claimed by at least one spec.
139
- const governed: string[] = []
140
- for (const r of cfg.governedRoots) sourceFiles(root, r, governed, srcRe)
161
+ const governed = trackedSourceFiles(root, cfg.governedRoots, srcRe, cfg.testGlobs)
141
162
  // no governed source found at all → the defaults name this repo's own dirs, so an adopter who never set
142
163
  // lint.governedRoots would otherwise see a falsely-clean board. Make it loud and point at the knob.
143
164
  if (governed.length === 0)
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "lint": {
3
- "governedRoots": ["src"]
3
+ "governedRoots": ["."]
4
4
  }
5
5
  }