spexcode 0.5.7 → 0.5.8

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.5.7",
3
+ "version": "0.5.8",
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",
@@ -505,15 +505,24 @@ export function windowEvents(idx: DriftIndex, sinceHash: string, path: string, n
505
505
  // through the current filename. Several selectors are OR'd and one commit still produces one hit row.
506
506
  // An historical image the designated extractor cannot parse is a conservative hit (`unparseable`).
507
507
  export type AnchorHit = { commit: string; selectors: string[]; unparseable?: string }
508
- export async function anchorHitCommits(root: string, win: DriftPathEvent[], symbols: string[], regs: Extractor[]): Promise<AnchorHit[]> {
509
- const hits = new Map<string, { selectors: Set<string>; unparseable?: string }>()
508
+ export type AnchorHitQuery = { win: DriftPathEvent[]; symbols: string[] }
509
+ type AnchorRevision = { commit: string; path: string }
510
+ const anchorRevisionKey = ({ commit, path }: AnchorRevision) => `${commit}\0${path}`
511
+
512
+ // One lint can judge several selectors with overlapping windows. Their Git images and ordinary hunks are
513
+ // immutable facts, so the batch owns them once and each query keeps its own selector verdict.
514
+ export async function anchorHitQueries(root: string, queries: AnchorHitQuery[], regs: Extractor[]): Promise<AnchorHit[][]> {
515
+ if (!queries.length) return []
510
516
  const objectFormat = gitObjectFormat(root)
511
- type Revision = { commit: string; path: string }
512
- const revisionKey = ({ commit, path }: Revision) => `${commit}\0${path}`
513
- const revisions = new Map<string, Revision>()
514
- for (const event of win) {
517
+ const revisions = new Map<string, AnchorRevision>()
518
+ const ordinaryByPath = new Map<string, Set<string>>()
519
+ for (const { win } of queries) for (const event of win) {
515
520
  const refs = [{ commit: event.commit, path: event.historicalPath }, ...event.parents.map(({ commit, historicalPath }) => ({ commit, path: historicalPath }))]
516
- for (const ref of refs) revisions.set(revisionKey(ref), ref)
521
+ for (const ref of refs) revisions.set(anchorRevisionKey(ref), ref)
522
+ if (event.parents.length > 1 || event.parents.some((parent) => parent.historicalPath !== event.historicalPath)) continue
523
+ const commits = ordinaryByPath.get(event.historicalPath) ?? new Set<string>()
524
+ commits.add(event.commit)
525
+ ordinaryByPath.set(event.historicalPath, commits)
517
526
  }
518
527
  const refs = [...revisions.values()]
519
528
  const oids = batchRevisionOids(root, refs.map(({ commit, path }) => `${commit}:${path}`))
@@ -524,44 +533,45 @@ export async function anchorHitCommits(root: string, win: DriftPathEvent[], symb
524
533
  const x = extractorFor(regs, extOf(ref.path))
525
534
  const ready = x?.ready()
526
535
  if (!x || ready !== true) {
527
- units.set(revisionKey(ref), { unparseable: !x ? `no designated extractor for ${ref.path}` : String(ready) })
536
+ units.set(anchorRevisionKey(ref), { unparseable: !x ? `no designated extractor for ${ref.path}` : String(ready) })
528
537
  continue
529
538
  }
530
- units.set(revisionKey(ref), await unitsAtFileRevision(ref.commit, ref.path, x, objectFormat, oid, oid ? blobs.get(oid) : undefined))
531
- }
532
- const byPath = new Map<string, string[]>()
533
- for (const event of win) {
534
- if (event.parents.length > 1 || event.parents.some((parent) => parent.historicalPath !== event.historicalPath)) continue
535
- const commits = byPath.get(event.historicalPath) ?? []
536
- commits.push(event.commit)
537
- byPath.set(event.historicalPath, commits)
539
+ units.set(anchorRevisionKey(ref), await unitsAtFileRevision(ref.commit, ref.path, x, objectFormat, oid, oid ? blobs.get(oid) : undefined))
538
540
  }
539
541
  const ordinaryHunks = new Map<string, Map<string, HunkRanges>>()
540
- for (const [path, commits] of byPath) ordinaryHunks.set(path, await hunksAtMany(root, commits, path))
542
+ for (const [path, commits] of ordinaryByPath) ordinaryHunks.set(path, await hunksAtMany(root, [...commits], path))
541
543
  const intersects = (ranges: DiffLineRange[], candidates: Unit[]) =>
542
544
  ranges.some(([start, end]) => candidates.some((unit) => start <= unit.end && unit.start <= end))
543
- for (const event of win) {
544
- const after = units.get(revisionKey({ commit: event.commit, path: event.historicalPath }))!
545
- const before = event.parents.map(({ commit, historicalPath }) => units.get(revisionKey({ commit, path: historicalPath }))!)
546
- const ranges = ordinaryHunks.get(event.historicalPath)?.get(event.commit)
547
- ?? await hunksAt(root, event)
548
- if (event.parents.length && ranges.before.length !== before.length)
549
- throw new Error(`anchor diff for ${event.commit}:${event.historicalPath} has ${ranges.before.length} parent ranges for ${before.length} parents`)
550
- const hit = hits.get(event.commit) ?? { selectors: new Set<string>() }
551
- const broken = [after, ...before].find((image) => 'unparseable' in image)
552
- if (broken && 'unparseable' in broken) {
553
- for (const symbol of symbols) hit.selectors.add(symbol)
554
- hit.unparseable = broken.unparseable
555
- } else {
556
- for (const symbol of symbols) {
557
- const afterUnits = 'units' in after ? after.units.filter((unit) => unit.name === symbol) : []
558
- const authoredAfter = intersects(ranges.after, afterUnits)
559
- const authoredBefore = before.length > 0 && before.every((image, parent) =>
560
- 'units' in image && intersects(ranges.before[parent], image.units.filter((unit) => unit.name === symbol)))
561
- if (authoredAfter || authoredBefore) hit.selectors.add(symbol)
545
+ const results: AnchorHit[][] = []
546
+ for (const { win, symbols } of queries) {
547
+ const hits = new Map<string, { selectors: Set<string>; unparseable?: string }>()
548
+ for (const event of win) {
549
+ const after = units.get(anchorRevisionKey({ commit: event.commit, path: event.historicalPath }))!
550
+ const before = event.parents.map(({ commit, historicalPath }) => units.get(anchorRevisionKey({ commit, path: historicalPath }))!)
551
+ const ranges = ordinaryHunks.get(event.historicalPath)?.get(event.commit)
552
+ ?? await hunksAt(root, event)
553
+ if (event.parents.length && ranges.before.length !== before.length)
554
+ throw new Error(`anchor diff for ${event.commit}:${event.historicalPath} has ${ranges.before.length} parent ranges for ${before.length} parents`)
555
+ const hit = hits.get(event.commit) ?? { selectors: new Set<string>() }
556
+ const broken = [after, ...before].find((image) => 'unparseable' in image)
557
+ if (broken && 'unparseable' in broken) {
558
+ for (const symbol of symbols) hit.selectors.add(symbol)
559
+ hit.unparseable = broken.unparseable
560
+ } else {
561
+ for (const symbol of symbols) {
562
+ const afterUnits = 'units' in after ? after.units.filter((unit) => unit.name === symbol) : []
563
+ const authoredAfter = intersects(ranges.after, afterUnits)
564
+ const authoredBefore = before.length > 0 && before.every((image, parent) =>
565
+ 'units' in image && intersects(ranges.before[parent], image.units.filter((unit) => unit.name === symbol)))
566
+ if (authoredAfter || authoredBefore) hit.selectors.add(symbol)
567
+ }
562
568
  }
569
+ if (hit.selectors.size) hits.set(event.commit, hit)
563
570
  }
564
- if (hit.selectors.size) hits.set(event.commit, hit)
571
+ results.push([...hits].map(([commit, hit]) => ({ commit, selectors: [...hit.selectors], ...(hit.unparseable ? { unparseable: hit.unparseable } : {}) })))
565
572
  }
566
- return [...hits].map(([commit, hit]) => ({ commit, selectors: [...hit.selectors], ...(hit.unparseable ? { unparseable: hit.unparseable } : {}) }))
573
+ return results
574
+ }
575
+ export async function anchorHitCommits(root: string, win: DriftPathEvent[], symbols: string[], regs: Extractor[]): Promise<AnchorHit[]> {
576
+ return (await anchorHitQueries(root, [{ win, symbols }], regs))[0]
567
577
  }
@@ -1,9 +1,9 @@
1
1
  import { readFileSync, existsSync, statSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import { repoRoot, git, sourceIndexes, rowsFor, treeFilePaths, treeFileText } from './git.js'
3
+ import { repoRoot, git, sourceIndexes, rowsFor, treeFilePaths, treeFileText, type DriftPathEvent } from './git.js'
4
4
  import { loadSpecs, parseFrontmatter } from './specs.js'
5
5
  import { readJsonConfig } from './layout.js'
6
- import { extractors, extractorFor, extOf, parseCodeEntry, relationClaimsPath, resolveAnchor, windowEvents, anchorHitCommits } from './anchors.js'
6
+ import { extractors, extractorFor, extOf, parseCodeEntry, relationClaimsPath, resolveAnchor, windowEvents, anchorHitQueries } from './anchors.js'
7
7
  import { DEFAULT_TEST_GLOBS, sourcePolicyDescription, trackedSourceFiles } from './source-files.js'
8
8
 
9
9
  export type Finding = { level: 'error' | 'warn'; rule: string; spec?: string; file?: string; msg: string }
@@ -321,25 +321,28 @@ export async function specLint(root = repoRoot(), regs = extractors(root), optio
321
321
  // silent for either relation: a dead or ambiguous selector, a selector on a directory, and an
322
322
  // unparseable working-tree file ERROR. An extension with no designated extractor, or a designated
323
323
  // extractor that cannot run here, also ERRORS but skips those anchors so the remaining checks continue.
324
+ type AnchorLintQuery = { id: string; version: number; relation: 'code' | 'related'; path: string; symbols: string[]; win: DriftPathEvent[] }
325
+ type AnchorLintStep = { finding: Finding } | { query: AnchorLintQuery }
324
326
  const readyWarned = new Set<string>()
327
+ const anchorSteps: AnchorLintStep[] = []
325
328
  for (const s of specs) {
326
329
  for (const { relation, entries } of [{ relation: 'code' as const, entries: s.codeScoped }, { relation: 'related' as const, entries: s.relatedScoped }]) {
327
330
  for (const { path, selectors } of entries) {
328
331
  if (pending && !changed.some((file) => relationClaimsPath(path, file))) continue
329
332
  const x = extractorFor(regs, extOf(path))
330
333
  if (!x) {
331
- 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 — anchor validation was skipped and remains unverified; add a LangSpec row (anchors.ts) or drop the selector(s)` })
334
+ anchorSteps.push({ finding: { 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 — anchor validation was skipped and remains unverified; add a LangSpec row (anchors.ts) or drop the selector(s)` } })
332
335
  continue
333
336
  }
334
337
  const ready = x.ready()
335
338
  if (ready !== true) {
336
339
  // once per (extractor, reason), even across several anchored nodes — one repair, one message.
337
- 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}` }) }
340
+ if (!readyWarned.has(x.id + ready)) { readyWarned.add(x.id + ready); anchorSteps.push({ finding: { level: 'error', rule: 'integrity', msg: `anchor extractor '${x.id}' cannot run: ${ready}` } }) }
338
341
  continue
339
342
  }
340
343
  if (!existsAtTip(path)) continue // the missing FILE already errored above
341
344
  if (isDirectoryAtTip(path)) {
342
- 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` })
345
+ anchorSteps.push({ finding: { 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` } })
343
346
  continue
344
347
  }
345
348
  let units
@@ -348,7 +351,7 @@ export async function specLint(root = repoRoot(), regs = extractors(root), optio
348
351
  if (source === null) throw new Error(`candidate tree has no file '${path}'`)
349
352
  units = x.extract(source, path)
350
353
  } catch (e: any) {
351
- 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}` })
354
+ anchorSteps.push({ finding: { 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}` } })
352
355
  continue
353
356
  }
354
357
  // each selector resolves (or errors) on its own; only the live ones feed the window engine.
@@ -356,43 +359,50 @@ export async function specLint(root = repoRoot(), regs = extractors(root), optio
356
359
  for (const sym of selectors) {
357
360
  const res = resolveAnchor(units, sym)
358
361
  if ('dead' in res) {
359
- 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` })
362
+ anchorSteps.push({ finding: { 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` } })
360
363
  continue
361
364
  }
362
365
  if ('ambiguous' in res) {
363
- 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` })
366
+ anchorSteps.push({ finding: { 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` } })
364
367
  continue
365
368
  }
366
369
  if (res.ok.typeOnly)
367
- 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` })
370
+ anchorSteps.push({ finding: { 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` } })
368
371
  live.push(sym)
369
372
  }
370
373
  if (!live.length) continue
371
374
  const since = rowsFor(hidx, s.path)[0]?.hash || ''
372
375
  const win = windowEvents(didx, since, path, s.id)
373
376
  if (!win.length) continue
374
- const hits = await anchorHitCommits(root, win, live, regs)
375
- if (!hits.length) continue
376
- const hitSyms = [...new Set(hits.flatMap((h) => h.selectors))]
377
- const shas = hits.map((h) => h.commit.slice(0, 8)).join(', ')
378
- const unparseable = hits.filter((h) => h.unparseable)
379
- const parseNote = unparseable.length ? ` (${unparseable.length} of these could not be parsed at that commit — counted as hits conservatively)` : ''
380
- if (relation === 'code') {
381
- const current = pending && hits.some((hit) => hit.commit === tip)
382
- const older = pending && hits.some((hit) => hit.commit !== tip)
383
- const remedy = !pending
384
- ? `update the spec, or 'spex spec ack ${s.id} --reason "…"' if the contract still holds`
385
- : current && older
386
- ? `update the spec in this commit; its own hit can be declared by retrying with 'git commit --trailer "Spec-OK: ${s.id}" …', but the listed older debt must be cleared first (an in-commit declaration never pardons ancestors)`
387
- : current
388
- ? `update the spec in this commit, or retry with 'git commit --trailer "Spec-OK: ${s.id}" …'; a later empty ack cannot pre-author this candidate`
389
- : `update the spec in this commit, or clear this older debt with 'spex spec ack ${s.id} --reason "…"' before retrying the candidate`
390
- 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: ${remedy}` })
391
- } else
392
- 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)` })
377
+ anchorSteps.push({ query: { id: s.id, version: s.version, relation, path, symbols: live, win } })
393
378
  }
394
379
  }
395
380
  }
381
+ const anchorHits = await anchorHitQueries(root, anchorSteps.flatMap((step) => 'query' in step ? [{ win: step.query.win, symbols: step.query.symbols }] : []), regs)
382
+ let hitIndex = 0
383
+ for (const step of anchorSteps) {
384
+ if ('finding' in step) { out.push(step.finding); continue }
385
+ const { id, version, relation, path } = step.query
386
+ const hits = anchorHits[hitIndex++]
387
+ if (!hits.length) continue
388
+ const hitSyms = [...new Set(hits.flatMap((h) => h.selectors))]
389
+ const shas = hits.map((h) => h.commit.slice(0, 8)).join(', ')
390
+ const unparseable = hits.filter((h) => h.unparseable)
391
+ const parseNote = unparseable.length ? ` (${unparseable.length} of these could not be parsed at that commit — counted as hits conservatively)` : ''
392
+ if (relation === 'code') {
393
+ const current = pending && hits.some((hit) => hit.commit === tip)
394
+ const older = pending && hits.some((hit) => hit.commit !== tip)
395
+ const remedy = !pending
396
+ ? `update the spec, or 'spex spec ack ${id} --reason "…"' if the contract still holds`
397
+ : current && older
398
+ ? `update the spec in this commit; its own hit can be declared by retrying with 'git commit --trailer "Spec-OK: ${id}" …', but the listed older debt must be cleared first (an in-commit declaration never pardons ancestors)`
399
+ : current
400
+ ? `update the spec in this commit, or retry with 'git commit --trailer "Spec-OK: ${id}" …'; a later empty ack cannot pre-author this candidate`
401
+ : `update the spec in this commit, or clear this older debt with 'spex spec ack ${id} --reason "…"' before retrying the candidate`
402
+ out.push({ level: 'error', rule: 'anchor-drift', spec: id, file: path, msg: `${path}#${hitSyms.join(', #')} was changed by ${hits.length} commit(s) since spec '${id}' v${version} [${shas}]${parseNote} — the anchored contract's code moved: ${remedy}` })
403
+ } else
404
+ out.push({ level: 'warn', rule: 'related-drift', spec: id, file: path, msg: `related ${path}#${hitSyms.join(', #')} ('${id}') was changed by ${hits.length} commit(s) since v${version} [${shas}]${parseNote} — a scoped dependency shifted, worth a glance (SOFT: never blocks, no ack, no eval staleness)` })
405
+ }
396
406
 
397
407
  // drift: a governed file has commits NOT yet reflected in its spec. Judged by true git ancestry —
398
408
  // loadSpecs computes `driftFiles` via driftFor() over the one cached driftIndex walk (git.ts): a