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 +1 -1
- package/spec-cli/src/anchors.ts +48 -38
- package/spec-cli/src/lint.ts +38 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spexcode",
|
|
3
|
-
"version": "0.5.
|
|
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",
|
package/spec-cli/src/anchors.ts
CHANGED
|
@@ -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
|
|
509
|
-
|
|
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
|
-
|
|
512
|
-
const
|
|
513
|
-
const
|
|
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(
|
|
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(
|
|
536
|
+
units.set(anchorRevisionKey(ref), { unparseable: !x ? `no designated extractor for ${ref.path}` : String(ready) })
|
|
528
537
|
continue
|
|
529
538
|
}
|
|
530
|
-
units.set(
|
|
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
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
const
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
const
|
|
560
|
-
'units' in
|
|
561
|
-
|
|
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
|
-
|
|
571
|
+
results.push([...hits].map(([commit, hit]) => ({ commit, selectors: [...hit.selectors], ...(hit.unparseable ? { unparseable: hit.unparseable } : {}) })))
|
|
565
572
|
}
|
|
566
|
-
return
|
|
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
|
}
|
package/spec-cli/src/lint.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|