@sporhq/spor 0.18.6 → 0.19.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/GRAPH.md +41 -0
- package/README.md +3 -0
- package/bin/spor-hook.js +16 -1
- package/bin/spor.js +166 -0
- package/lib/check.js +162 -0
- package/lib/config.js +16 -1
- package/lib/kernel/coupling.js +216 -0
- package/lib/kernel/graph.js +42 -2
- package/lib/seed/schema-question.md +35 -4
- package/package.json +2 -1
- package/scripts/engines/capture-health.js +82 -0
- package/scripts/engines/doctor.js +29 -0
- package/scripts/engines/enable-hint.js +57 -0
- package/scripts/engines/post-tool.js +198 -4
- package/scripts/engines/prompt-context.js +8 -0
- package/scripts/engines/session-start.js +12 -0
- package/skills/next/SKILL.md +1 -1
- package/skills/spor/SKILL.md +23 -8
- package/skills/spor/references/concepts.md +38 -1
- package/skills/{team-graph/SKILL.md → spor/references/cowork.md} +13 -19
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "spor",
|
|
3
3
|
"displayName": "Spor Context Compiler",
|
|
4
4
|
"description": "Maintains a typed, versioned knowledge graph and compiles compact briefings from it: session-start injection, per-prompt relevance digests, capture at discovery, end-of-session distillation, decision queue.",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.19.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "losthammer"
|
|
8
8
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Maintains a typed, versioned knowledge graph and compiles compact briefings from it: session-start injection, per-prompt relevance digests, capture at discovery, end-of-session distillation, decision queue.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Spor",
|
package/GRAPH.md
CHANGED
|
@@ -444,6 +444,43 @@ opt-in that turns scoped norms on; set them with `spor repos tag <slug>
|
|
|
444
444
|
keeps the project-scoped behavior above, so a graph using no `applies_to_*` is
|
|
445
445
|
byte-identical (norm-cc-byte-identical-refactor).
|
|
446
446
|
|
|
447
|
+
A norm may also declare **coupling anchors**
|
|
448
|
+
(dec-spor-coupling-norms-declared-first): two flat inline lists, `couples_when:`
|
|
449
|
+
(trigger file globs — "when files matching these change") and `couples_also:`
|
|
450
|
+
(the coupled artifacts that should change in the same edit, or be consciously
|
|
451
|
+
dismissed). A norm carrying both becomes a **coupling norm**: the post-tool
|
|
452
|
+
hook glob-matches every Write/Edit's repo-relative path against the trigger
|
|
453
|
+
sets and, on a hit, injects the targets as an edit-time nudge — once per
|
|
454
|
+
session per norm, deterministic, no LLM (task-spor-coupling-nudge-posttool;
|
|
455
|
+
`SPOR_COUPLING_NUDGE=0` disables). The glob dialect is small: `**` crosses
|
|
456
|
+
path segments, `*` stays within one, `?` is one character, a trailing `/`
|
|
457
|
+
means the whole subtree, and a bare `API.md` anchors at the repo root. An
|
|
458
|
+
entry may be **repo-qualified** as `<slug>:<glob>` (or `repo-<slug>:<glob>`)
|
|
459
|
+
so one norm couples artifacts across repos — a qualified trigger fires only in
|
|
460
|
+
that repo and bypasses the norm's scope (the pin IS the scope), while an
|
|
461
|
+
unqualified trigger applies wherever the norm itself applies: its
|
|
462
|
+
`applies_to_*` selectors when declared, else its `project:` stamp (unstamped =
|
|
463
|
+
every repo — the org-wide case, e.g. `couples_when: [.nvmrc]` /
|
|
464
|
+
`couples_also: [Dockerfile]`). Targets may be qualified the same way for
|
|
465
|
+
display. Either key alone, a scalar value, or the keys on a non-norm type are
|
|
466
|
+
inert (validate warns). The matcher is `lib/kernel/coupling.js`; a graph with
|
|
467
|
+
no coupling norms is byte-identical.
|
|
468
|
+
|
|
469
|
+
The boundary-time consumer of the same anchors is **`spor check`**
|
|
470
|
+
(task-spor-cli-check-coupling-verb): given a change set (uncommitted vs HEAD
|
|
471
|
+
by default; `--staged`, `--range a..b`, or `--files`), it reports each
|
|
472
|
+
coupling norm whose triggers are touched while its same-repo targets are not —
|
|
473
|
+
advisory by default, `--strict` exits 1 for CI/pre-commit; targets pinned to
|
|
474
|
+
another repo surface as reminders, never failures. A coupling norm may
|
|
475
|
+
additionally declare a machine-checkable **value invariant** — two scalar
|
|
476
|
+
keys, `couples_value_a:`/`couples_value_b:`, each `<path>#<regex>` (first
|
|
477
|
+
capture group = the value, e.g. `couples_value_a: .nvmrc#v?(\d+)` /
|
|
478
|
+
`couples_value_b: Dockerfile#FROM node:(\d+)`) — and `spor check` compares the
|
|
479
|
+
two extracted values: "these now disagree" beats "you probably forgot", an
|
|
480
|
+
agreeing invariant suppresses the untouched heuristic, and a disagreement
|
|
481
|
+
reports even when both files were touched. A half-declared or malformed pair
|
|
482
|
+
is inert (validate warns).
|
|
483
|
+
|
|
447
484
|
Because a norm rides along with no relevance gate and the team trust model lets
|
|
448
485
|
every writer author one, the briefing renderer treats norm bodies as an
|
|
449
486
|
**injection surface** (issue-cc-norm-always-on-injection): each is quoted as
|
|
@@ -616,6 +653,10 @@ edges:
|
|
|
616
653
|
the deterministic router walks `stewards` edges from the question's relevance
|
|
617
654
|
neighborhood to the closest steward and writes a `routed-to` edge to that
|
|
618
655
|
person; an unrouted question (no steward matched) surfaces to everyone.
|
|
656
|
+
The question schema's `validate()` door rejects a title/summary/body that is
|
|
657
|
+
ONLY an unfilled template token (`<question>`, `{{text}}`, `[id]`) so a docs
|
|
658
|
+
example run verbatim can't mint an information-free routed ask
|
|
659
|
+
(issue-spor-ask-question-template-placeholder-validation).
|
|
619
660
|
- **`assigned`** points work (task/issue) at a person; per-person queues filter
|
|
620
661
|
on it (the queue's `assignee` parameter — `GET /v1/queue?assignee=<person>`,
|
|
621
662
|
unioned with the person's `stewards` edges; QUEUE.md §5). **`answers`** points
|
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ It keeps track of the useful things that normally disappear into chat history: d
|
|
|
6
6
|
|
|
7
7
|
The name comes from Norwegian: *spor* means the track something leaves behind.
|
|
8
8
|
|
|
9
|
+
📖 **Full documentation: [docs.sporhq.io](https://docs.sporhq.io)** — concepts, getting started, the CLI, the REST API, the MCP surface, and the hosted product.
|
|
10
|
+
|
|
9
11
|
## Why use Spor?
|
|
10
12
|
|
|
11
13
|
Coding agents are good at working inside a single session. The problem is everything before and after that session.
|
|
@@ -515,6 +517,7 @@ This matters because some hosts cache plugins or hook definitions. Updating the
|
|
|
515
517
|
|
|
516
518
|
## More docs
|
|
517
519
|
|
|
520
|
+
* [docs.sporhq.io](https://docs.sporhq.io) — the full documentation site: concepts, getting started, CLI, REST API, MCP, and the hosted guide
|
|
518
521
|
* `GRAPH.md` — graph format, node types, edges, and schema behaviour
|
|
519
522
|
* `API.md` — REST and MCP server contract
|
|
520
523
|
* `QUEUE.md` — queue, capture, routing, and workflow details
|
package/bin/spor-hook.js
CHANGED
|
@@ -162,7 +162,18 @@ async function main() {
|
|
|
162
162
|
/* logging must never break fail-open */
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
|
-
if (!cfg.enabled())
|
|
165
|
+
if (!cfg.enabled()) {
|
|
166
|
+
// A repo disabled purely by the opt-in DEFAULT that this machine has prior
|
|
167
|
+
// Spor history for gets a one-time discovery hint instead of silence
|
|
168
|
+
// (issue-spor-opt-in-silent-disable-no-indication) — session-start only,
|
|
169
|
+
// evidence-gated, once per repo; see scripts/engines/enable-hint.js.
|
|
170
|
+
if (event === "session-start") {
|
|
171
|
+
const { enableHint } = require("../scripts/engines/enable-hint");
|
|
172
|
+
const hint = enableHint(payload);
|
|
173
|
+
if (hint) emit(hint, payload, host);
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
166
177
|
|
|
167
178
|
// Debounced distill: spool the payload and hand off to a per-session
|
|
168
179
|
// watcher (one at a time — the lock holds the watcher's pid; stale locks
|
|
@@ -204,7 +215,11 @@ async function main() {
|
|
|
204
215
|
const engine = ENGINES[event]();
|
|
205
216
|
const out = await engine(payload);
|
|
206
217
|
if (!out) return;
|
|
218
|
+
emit(out, payload, host);
|
|
219
|
+
}
|
|
207
220
|
|
|
221
|
+
// Write an engine's output envelope to stdout in the host's dialect.
|
|
222
|
+
function emit(out, payload, host) {
|
|
208
223
|
// Cursor speaks a flat snake_case output: {additional_context} only.
|
|
209
224
|
// (jq -c framing: compact JSON with a trailing newline.)
|
|
210
225
|
if (host === "cursor") {
|
package/bin/spor.js
CHANGED
|
@@ -1701,6 +1701,140 @@ async function fetchRemoteExportNodes(cfg, label) {
|
|
|
1701
1701
|
}
|
|
1702
1702
|
}
|
|
1703
1703
|
|
|
1704
|
+
// `spor check` — the coupling-drift report over a diff
|
|
1705
|
+
// (task-spor-cli-check-coupling-verb, dec-spor-coupling-norms-declared-first):
|
|
1706
|
+
// the boundary-time consumer of the same coupling norms the post-tool nudge
|
|
1707
|
+
// serves at edit time (one matcher, lib/kernel/coupling.js). Resolve a change
|
|
1708
|
+
// set via LOCAL git (always — the diff is where you run), load the graph's
|
|
1709
|
+
// coupling norms (local nodes dir, or the team graph via GET /v1/export in
|
|
1710
|
+
// remote mode, the documented graph-wide-sweep path), and report each norm
|
|
1711
|
+
// whose `couples_when` triggers are touched while its `couples_also` targets
|
|
1712
|
+
// are not — plus value-invariant disagreement for norms carrying
|
|
1713
|
+
// couples_value_a/b. Advisory by default (exit 0); --strict exits 1 on
|
|
1714
|
+
// findings for CI/pre-commit enforcement.
|
|
1715
|
+
async function cmdCheck(cfg, args) {
|
|
1716
|
+
const cwd = process.cwd();
|
|
1717
|
+
const topR = git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
1718
|
+
const top = topR.status === 0 ? topR.stdout.trim() : "";
|
|
1719
|
+
if (!top) {
|
|
1720
|
+
err("check: not inside a git repository (the change set is a git diff)");
|
|
1721
|
+
return 1;
|
|
1722
|
+
}
|
|
1723
|
+
const slug = u.projectSlug(top);
|
|
1724
|
+
const range = optVal(args, "range");
|
|
1725
|
+
const staged = args.includes("--staged");
|
|
1726
|
+
const strict = args.includes("--strict");
|
|
1727
|
+
const json = args.includes("--json");
|
|
1728
|
+
let files = null;
|
|
1729
|
+
const fi = args.indexOf("--files");
|
|
1730
|
+
if (fi >= 0) {
|
|
1731
|
+
files = [];
|
|
1732
|
+
for (let i = fi + 1; i < args.length && !String(args[i]).startsWith("--"); i++) files.push(args[i]);
|
|
1733
|
+
if (!files.length) {
|
|
1734
|
+
err("check: --files needs at least one path");
|
|
1735
|
+
return 1;
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
if ((range ? 1 : 0) + (staged ? 1 : 0) + (files ? 1 : 0) > 1) {
|
|
1739
|
+
err("check: --range, --staged, and --files are mutually exclusive");
|
|
1740
|
+
return 1;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// The change set, as repo-relative forward-slash paths (git's own spelling).
|
|
1744
|
+
let changed = [];
|
|
1745
|
+
let rightRev = null; // where value invariants read from (--range reads the range's right side)
|
|
1746
|
+
if (files) {
|
|
1747
|
+
changed = files.map((f) => {
|
|
1748
|
+
const abs = path.isAbsolute(f) ? f : path.resolve(cwd, f);
|
|
1749
|
+
return path.relative(top, abs).split(path.sep).join("/");
|
|
1750
|
+
});
|
|
1751
|
+
} else if (range) {
|
|
1752
|
+
const r = git(top, ["diff", "--name-only", range]);
|
|
1753
|
+
if (r.status !== 0) {
|
|
1754
|
+
err(`check: could not resolve --range '${range}' (${(r.stderr || "").trim().split("\n")[0]})`);
|
|
1755
|
+
return 1;
|
|
1756
|
+
}
|
|
1757
|
+
changed = r.stdout.split("\n").filter(Boolean);
|
|
1758
|
+
const i = range.lastIndexOf("..");
|
|
1759
|
+
rightRev = i > 0 ? range.slice(i + 2) || "HEAD" : null;
|
|
1760
|
+
} else if (staged) {
|
|
1761
|
+
const r = git(top, ["diff", "--name-only", "--cached"]);
|
|
1762
|
+
if (r.status !== 0) {
|
|
1763
|
+
err("check: git diff --cached failed");
|
|
1764
|
+
return 1;
|
|
1765
|
+
}
|
|
1766
|
+
changed = r.stdout.split("\n").filter(Boolean);
|
|
1767
|
+
} else {
|
|
1768
|
+
// Default: everything uncommitted vs HEAD (staged + unstaged) plus
|
|
1769
|
+
// untracked files — the mid-session / pre-commit superset, so an unstaged
|
|
1770
|
+
// edit can't silently pass. (The task sketch said "staged by default";
|
|
1771
|
+
// staged-only reports NOTHING for the common unstaged-working-tree case —
|
|
1772
|
+
// false confidence — so the default is the honest superset and --staged is
|
|
1773
|
+
// the narrow pre-commit view.) A repo with no commits yet falls back to
|
|
1774
|
+
// the index.
|
|
1775
|
+
const d = git(top, ["diff", "--name-only", "HEAD"]);
|
|
1776
|
+
if (d.status === 0) changed = d.stdout.split("\n").filter(Boolean);
|
|
1777
|
+
else {
|
|
1778
|
+
const c = git(top, ["diff", "--name-only", "--cached"]);
|
|
1779
|
+
if (c.status === 0) changed = c.stdout.split("\n").filter(Boolean);
|
|
1780
|
+
}
|
|
1781
|
+
const un = git(top, ["ls-files", "--others", "--exclude-standard"]);
|
|
1782
|
+
if (un.status === 0) changed.push(...un.stdout.split("\n").filter(Boolean));
|
|
1783
|
+
}
|
|
1784
|
+
changed = [...new Set(changed)];
|
|
1785
|
+
|
|
1786
|
+
// The coupling norms: local nodes dir, or the team graph via /v1/export.
|
|
1787
|
+
const couplingLib = require(path.join(ROOT, "lib", "kernel", "coupling.js"));
|
|
1788
|
+
const scanDir = (nodesDir) => {
|
|
1789
|
+
let names = [];
|
|
1790
|
+
try {
|
|
1791
|
+
names = fs.readdirSync(nodesDir).sort();
|
|
1792
|
+
} catch {}
|
|
1793
|
+
return couplingLib.scanCouplingEntries((f) => fs.readFileSync(path.join(nodesDir, f), "utf8"), names);
|
|
1794
|
+
};
|
|
1795
|
+
let scan;
|
|
1796
|
+
let cleanupFetched = null;
|
|
1797
|
+
if (cfg.mode() === "remote" && !namesLocalGraph(args)) {
|
|
1798
|
+
const fetched = await fetchRemoteExportNodes(cfg, "check");
|
|
1799
|
+
if (fetched.error) return 1; // already reported (offline / HTTP error)
|
|
1800
|
+
cleanupFetched = fetched.cleanup;
|
|
1801
|
+
scan = scanDir(fetched.nodesDir);
|
|
1802
|
+
} else {
|
|
1803
|
+
const nodesDir = optVal(args, "nodes") || cfg.nodesDir();
|
|
1804
|
+
if (!fs.existsSync(nodesDir)) {
|
|
1805
|
+
err(`no graph at ${nodesDir} — run 'spor init' first`);
|
|
1806
|
+
return 1;
|
|
1807
|
+
}
|
|
1808
|
+
scan = scanDir(nodesDir);
|
|
1809
|
+
}
|
|
1810
|
+
try {
|
|
1811
|
+
const checkLib = require(path.join(ROOT, "lib", "check.js"));
|
|
1812
|
+
const readFile = (rel) => {
|
|
1813
|
+
try {
|
|
1814
|
+
if (rightRev) {
|
|
1815
|
+
const r = git(top, ["show", `${rightRev}:${rel}`]);
|
|
1816
|
+
return r.status === 0 ? r.stdout : null;
|
|
1817
|
+
}
|
|
1818
|
+
return fs.readFileSync(path.join(top, rel), "utf8");
|
|
1819
|
+
} catch {
|
|
1820
|
+
return null;
|
|
1821
|
+
}
|
|
1822
|
+
};
|
|
1823
|
+
const { checked, findings, reminders } = checkLib.runCheck({
|
|
1824
|
+
slug,
|
|
1825
|
+
changed,
|
|
1826
|
+
norms: scan.norms,
|
|
1827
|
+
repoTags: scan.repo_tags[slug] ?? [],
|
|
1828
|
+
readFile,
|
|
1829
|
+
});
|
|
1830
|
+
if (json) out(JSON.stringify({ project: slug, changed, checked, findings, reminders, strict }, null, 2));
|
|
1831
|
+
else out(checkLib.renderReport({ slug, changed, checked, findings, reminders }, { strict }));
|
|
1832
|
+
return strict && findings.length ? 1 : 0;
|
|
1833
|
+
} finally {
|
|
1834
|
+
if (cleanupFetched) cleanupFetched();
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1704
1838
|
// analytics folds a graph's git history into created-vs-completed metrics
|
|
1705
1839
|
// (task-spor-work-analytics-consumer). Unlike query/validate (no server twin) it
|
|
1706
1840
|
// is dual-mode: local mode runs the in-repo consumer (lib/analytics.js) over
|
|
@@ -8331,6 +8465,38 @@ const COMMANDS = {
|
|
|
8331
8465
|
],
|
|
8332
8466
|
run: (cfg, args) => cmdChanges(cfg, args),
|
|
8333
8467
|
},
|
|
8468
|
+
check: {
|
|
8469
|
+
group: "Graph", parse: "raw", args: "[--staged|--range <a..b>|--files <f...>] [--strict] [--json]",
|
|
8470
|
+
summary: "coupling-drift report over a diff — triggers touched, targets not",
|
|
8471
|
+
help:
|
|
8472
|
+
"Check a change set against the graph's COUPLING NORMS (norm nodes carrying\n" +
|
|
8473
|
+
"couples_when/couples_also file globs — GRAPH.md \"coupling anchors\"): report\n" +
|
|
8474
|
+
"each norm whose trigger set is touched while its target set is not — the\n" +
|
|
8475
|
+
"boundary-time twin of the edit-time post-tool coupling nudge, sharing one\n" +
|
|
8476
|
+
"matcher (lib/kernel/coupling.js). A norm carrying a value invariant\n" +
|
|
8477
|
+
"(couples_value_a/b: <path>#<regex>) has its two extracted values compared:\n" +
|
|
8478
|
+
"\"these now disagree\" beats \"you probably forgot\", and an agreeing invariant\n" +
|
|
8479
|
+
"suppresses the untouched heuristic. Targets pinned to another repo are\n" +
|
|
8480
|
+
"surfaced as reminders, never failures (verify them there).\n" +
|
|
8481
|
+
"\n" +
|
|
8482
|
+
"The change set is always LOCAL git; the norms are mode-aware (local graph, or\n" +
|
|
8483
|
+
"the team graph via GET /v1/export in remote mode).\n" +
|
|
8484
|
+
"\n" +
|
|
8485
|
+
" --staged check the index only (the pre-commit view); default is\n" +
|
|
8486
|
+
" everything uncommitted vs HEAD plus untracked files\n" +
|
|
8487
|
+
" --range <a..b> check a commit range (CI; value invariants read the right side)\n" +
|
|
8488
|
+
" --files <f...> an explicit file list (paths, repo-relative or absolute)\n" +
|
|
8489
|
+
" --strict exit 1 when findings exist (CI / pre-commit enforcement)\n" +
|
|
8490
|
+
" --json machine-readable {project, changed, checked, findings}\n" +
|
|
8491
|
+
" --nodes <dir> read this local graph dir instead of the resolved home",
|
|
8492
|
+
examples: [
|
|
8493
|
+
"spor check",
|
|
8494
|
+
"spor check --staged --strict",
|
|
8495
|
+
"spor check --range origin/main..HEAD --strict",
|
|
8496
|
+
"spor check --files lib/seed/schema-task.md --json",
|
|
8497
|
+
],
|
|
8498
|
+
run: (cfg, args) => cmdCheck(cfg, args),
|
|
8499
|
+
},
|
|
8334
8500
|
export: {
|
|
8335
8501
|
group: "Graph", parse: "strict", args: "[--gzip] [--history|--auth] [--out <file>]",
|
|
8336
8502
|
summary: "the nodes/ tarball, or the --history bundle / --auth restore backup (GET /v1/export)",
|
package/lib/check.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// check.js — the coupling-drift checker behind `spor check`
|
|
3
|
+
// (task-spor-cli-check-coupling-verb, dec-spor-coupling-norms-declared-first):
|
|
4
|
+
// the boundary-time half of the coupling norms the post-tool nudge serves at
|
|
5
|
+
// edit time. Given a change set (repo-relative paths) and the graph's coupling
|
|
6
|
+
// norms, report each norm whose trigger set (`couples_when`) is touched while
|
|
7
|
+
// its target set (`couples_also`) is not — plus, for norms carrying a VALUE
|
|
8
|
+
// invariant (`couples_value_a`/`couples_value_b`), whether the two extracted
|
|
9
|
+
// values actually agree ("these two now disagree" beats "you probably
|
|
10
|
+
// forgot"). Pure over injected inputs — the CLI resolves git, mode, and norm
|
|
11
|
+
// loading; this module never touches the network.
|
|
12
|
+
|
|
13
|
+
const coupling = require("./kernel/coupling.js");
|
|
14
|
+
|
|
15
|
+
const arr = (v) => (Array.isArray(v) ? v : []);
|
|
16
|
+
|
|
17
|
+
// Does any changed path satisfy one couples_also target entry (same-repo view)?
|
|
18
|
+
function targetTouched(glob, changed) {
|
|
19
|
+
const re = coupling.globToRegExp(glob);
|
|
20
|
+
return changed.some((p) => re.test(p));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Check one norm's value invariant against the change. Returns null when the
|
|
24
|
+
// norm declares none, else:
|
|
25
|
+
// { checked: true, agree, a: {file, value}, b: {file, value} }
|
|
26
|
+
// { checked: false, reason: "cross-repo" | "malformed" | "unreadable", ... }
|
|
27
|
+
// `readFile(relPath)` returns file content or null (the CLI wires it to the
|
|
28
|
+
// working tree, or `git show <rev>:<path>` for --range).
|
|
29
|
+
function checkValueInvariant(norm, { slug, readFile }) {
|
|
30
|
+
if (norm.couples_value_a == null && norm.couples_value_b == null) return null;
|
|
31
|
+
const specA = coupling.parseValueSpec(norm.couples_value_a);
|
|
32
|
+
const specB = coupling.parseValueSpec(norm.couples_value_b);
|
|
33
|
+
if (!specA || !specB) return { checked: false, reason: "malformed" };
|
|
34
|
+
for (const s of [specA, specB]) {
|
|
35
|
+
if (s.repo !== null && !coupling.repoMatches(s.repo, slug)) {
|
|
36
|
+
return { checked: false, reason: "cross-repo", file: `${s.repo}:${s.file}` };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const contentA = readFile(specA.file);
|
|
40
|
+
const contentB = readFile(specB.file);
|
|
41
|
+
if (contentA == null || contentB == null) {
|
|
42
|
+
return { checked: false, reason: "unreadable", file: contentA == null ? specA.file : specB.file };
|
|
43
|
+
}
|
|
44
|
+
const a = coupling.extractValue(contentA, specA.pattern);
|
|
45
|
+
const b = coupling.extractValue(contentB, specB.pattern);
|
|
46
|
+
if (a == null || b == null) {
|
|
47
|
+
return { checked: false, reason: "unreadable", file: a == null ? specA.file : specB.file, pattern_miss: true };
|
|
48
|
+
}
|
|
49
|
+
return { checked: true, agree: a === b, a: { file: specA.file, value: a }, b: { file: specB.file, value: b } };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The check. Inputs:
|
|
53
|
+
// slug — this repo's project slug (scopes the norms, resolves qualifiers)
|
|
54
|
+
// changed — repo-relative forward-slash paths of the change set
|
|
55
|
+
// norms — coupling norms (scanCouplingEntries output)
|
|
56
|
+
// repoTags — this repo's `tags:` (feeds applies_to_tags scope)
|
|
57
|
+
// readFile — (relPath) -> content|null, for value invariants
|
|
58
|
+
// Returns { checked, findings, reminders }: `checked` counts the norms whose
|
|
59
|
+
// trigger set was evaluated against this change; findings are, per norm, one of
|
|
60
|
+
// kind "untouched" — triggers hit, same-repo targets untouched
|
|
61
|
+
// kind "value-disagreement" — the declared invariant's two values differ
|
|
62
|
+
// kind "value-unverifiable" — triggers hit but the invariant could not be
|
|
63
|
+
// read/parsed here (carries the untouched list)
|
|
64
|
+
// A norm whose invariant CHECKS and AGREES emits nothing even when target
|
|
65
|
+
// globs look untouched — the machine-checkable truth beats the heuristic
|
|
66
|
+
// (e.g. the Dockerfile already carried the right version).
|
|
67
|
+
// `reminders` are triggered norms whose OTHER-REPO targets can't be verified
|
|
68
|
+
// by this diff — surfaced as information, never findings (else every
|
|
69
|
+
// cross-repo coupling would fail --strict unconditionally); a norm appears
|
|
70
|
+
// there only when it produced no finding (a finding already carries its
|
|
71
|
+
// cross_repo list).
|
|
72
|
+
function runCheck({ slug, changed, norms, repoTags = [], readFile = () => null }) {
|
|
73
|
+
const findings = [];
|
|
74
|
+
const reminders = [];
|
|
75
|
+
let checkedCount = 0;
|
|
76
|
+
for (const norm of arr(norms)) {
|
|
77
|
+
if (!coupling.isCouplingNorm(norm)) continue;
|
|
78
|
+
checkedCount++;
|
|
79
|
+
const ctx = { slug, repoTags };
|
|
80
|
+
const triggered = arr(changed).filter((p) => coupling.couplingHit(norm, { ...ctx, relPath: p }));
|
|
81
|
+
const invariant = checkValueInvariant(norm, { slug, readFile });
|
|
82
|
+
// The invariant also arms on a change to either of its own files, so a
|
|
83
|
+
// target edited to the WRONG value is caught even when the norm's glob
|
|
84
|
+
// targets were all touched.
|
|
85
|
+
const invariantFiles = [norm.couples_value_a, norm.couples_value_b]
|
|
86
|
+
.map((s) => coupling.parseValueSpec(s))
|
|
87
|
+
.filter((s) => s && (s.repo === null || coupling.repoMatches(s.repo, slug)))
|
|
88
|
+
.map((s) => s.file);
|
|
89
|
+
const invariantArmed = triggered.length > 0 || arr(changed).some((p) => invariantFiles.includes(p));
|
|
90
|
+
if (!triggered.length && !(invariant && invariantArmed)) continue;
|
|
91
|
+
|
|
92
|
+
// Partition targets: same-repo ones are checkable against this diff;
|
|
93
|
+
// other-repo ones can only be surfaced as reminders.
|
|
94
|
+
const untouched = [];
|
|
95
|
+
const crossRepo = [];
|
|
96
|
+
for (const entry of arr(norm.couples_also)) {
|
|
97
|
+
const { repo, glob } = coupling.parseEntry(entry);
|
|
98
|
+
if (repo !== null && !coupling.repoMatches(repo, slug)) {
|
|
99
|
+
crossRepo.push(entry);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (!targetTouched(glob, arr(changed))) untouched.push(entry);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const base = { norm: norm.id, title: norm.title ?? null, triggered, cross_repo: crossRepo };
|
|
106
|
+
const before = findings.length;
|
|
107
|
+
if (invariant && invariantArmed) {
|
|
108
|
+
if (invariant.checked) {
|
|
109
|
+
if (!invariant.agree) {
|
|
110
|
+
findings.push({ kind: "value-disagreement", ...base, untouched, a: invariant.a, b: invariant.b });
|
|
111
|
+
}
|
|
112
|
+
// agree -> the invariant is authoritative; no untouched finding.
|
|
113
|
+
} else if (triggered.length && untouched.length) {
|
|
114
|
+
findings.push({ kind: "value-unverifiable", ...base, untouched, reason: invariant.reason, file: invariant.file ?? null });
|
|
115
|
+
}
|
|
116
|
+
} else if (triggered.length && untouched.length) {
|
|
117
|
+
findings.push({ kind: "untouched", ...base, untouched });
|
|
118
|
+
}
|
|
119
|
+
if (findings.length === before && triggered.length && crossRepo.length) {
|
|
120
|
+
reminders.push({ norm: norm.id, title: norm.title ?? null, triggered, cross_repo: crossRepo });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { checked: checkedCount, findings, reminders };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The human report. One block per finding; the summary line always prints so
|
|
127
|
+
// a clean run is visibly a run, not a silent no-op. Cross-repo reminders
|
|
128
|
+
// render after the findings, informational only.
|
|
129
|
+
function renderReport({ slug, changed, checked, findings, reminders = [] }, { strict = false } = {}) {
|
|
130
|
+
const lines = [];
|
|
131
|
+
const n = findings.length;
|
|
132
|
+
lines.push(
|
|
133
|
+
`spor check: ${n === 0 ? "ok" : `${n} finding${n === 1 ? "" : "s"}`} (${changed.length} changed file${changed.length === 1 ? "" : "s"}, ${checked} coupling norm${checked === 1 ? "" : "s"}, project ${slug})`
|
|
134
|
+
);
|
|
135
|
+
for (const f of findings) {
|
|
136
|
+
lines.push("");
|
|
137
|
+
lines.push(`${f.norm}${f.title ? ` — ${f.title}` : ""}`);
|
|
138
|
+
if (f.triggered.length) lines.push(` triggered by: ${f.triggered.join(", ")}`);
|
|
139
|
+
if (f.kind === "value-disagreement") {
|
|
140
|
+
lines.push(` value disagreement: ${f.a.file} (${f.a.value}) != ${f.b.file} (${f.b.value})`);
|
|
141
|
+
}
|
|
142
|
+
if (f.untouched && f.untouched.length && f.kind !== "value-disagreement") {
|
|
143
|
+
lines.push(` untouched: ${f.untouched.join(", ")}`);
|
|
144
|
+
}
|
|
145
|
+
if (f.kind === "value-unverifiable") {
|
|
146
|
+
lines.push(` value invariant not verifiable here (${f.reason}${f.file ? `: ${f.file}` : ""})`);
|
|
147
|
+
}
|
|
148
|
+
if (f.cross_repo && f.cross_repo.length) {
|
|
149
|
+
lines.push(` other-repo targets (verify there): ${f.cross_repo.join(", ")}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const r of reminders) {
|
|
153
|
+
lines.push("");
|
|
154
|
+
lines.push(`${r.norm}${r.title ? ` — ${r.title}` : ""} (reminder)`);
|
|
155
|
+
if (r.triggered.length) lines.push(` triggered by: ${r.triggered.join(", ")}`);
|
|
156
|
+
lines.push(` other-repo targets (verify there): ${r.cross_repo.join(", ")}`);
|
|
157
|
+
}
|
|
158
|
+
if (n > 0 && !strict) lines.push("", "(advisory — exit 0; run with --strict to fail on findings)");
|
|
159
|
+
return lines.join("\n");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = { runCheck, renderReport, checkValueInvariant };
|
package/lib/config.js
CHANGED
|
@@ -72,7 +72,7 @@ const REPO_FORBIDDEN_KEYS = ["token"];
|
|
|
72
72
|
// warning so a silently-ignored setting is visible rather than mysterious.
|
|
73
73
|
const KNOWN_KEYS = new Set([
|
|
74
74
|
"mode", "server", "token", "home", "nodes", "enabled", "org",
|
|
75
|
-
"search", "queue", "distill", "nudge", "claimNudge", "inferCommits", "dispatch",
|
|
75
|
+
"search", "queue", "distill", "nudge", "claimNudge", "couplingNudge", "inferCommits", "dispatch",
|
|
76
76
|
// Path-scoped sub-briefs for monorepos (dec-spor-monorepo-path-scoped-briefs):
|
|
77
77
|
// an optional relative-subtree-path -> brief-id map. Committable repo config,
|
|
78
78
|
// kept SEPARATE from the flat `.spor` identity marker; session-start routes to
|
|
@@ -101,6 +101,8 @@ const ENV_MAP = [
|
|
|
101
101
|
["NUDGE_TIMEOUT", "nudge.timeoutMs"], // bound a hung nudge backend (ms)
|
|
102
102
|
["CLAIM_NUDGE", "claimNudge.enabled"], // SPOR_CLAIM_NUDGE=0 disables the claim heartbeat+nudge
|
|
103
103
|
["CLAIM_NUDGE_TIMEOUT", "claimNudge.timeoutMs"], // bound the lease-lookup/heartbeat curls (ms)
|
|
104
|
+
["COUPLING_NUDGE", "couplingNudge.enabled"], // SPOR_COUPLING_NUDGE=0 disables the edit-time coupling nudge
|
|
105
|
+
["COUPLING_NUDGE_TIMEOUT", "couplingNudge.timeoutMs"], // bound the remote coupling-snapshot download (ms)
|
|
104
106
|
["INFER_COMMITS", "inferCommits.enabled"],
|
|
105
107
|
["INFER_THRESHOLD", "inferCommits.threshold"],
|
|
106
108
|
["QUEUE_FRONT", "queue.front.enabled"], // SPOR_QUEUE_FRONT=0 disables local git-derived front
|
|
@@ -661,6 +663,19 @@ class Config {
|
|
|
661
663
|
if (explicit !== undefined) return this.getBool("enabled", true);
|
|
662
664
|
return this._repoMarker;
|
|
663
665
|
}
|
|
666
|
+
// True iff the plugin is inactive purely by the opt-in DEFAULT — no explicit
|
|
667
|
+
// `enabled` flag anywhere in the cascade, no mode:off, no repo marker
|
|
668
|
+
// (issue-spor-opt-in-silent-disable-no-indication). This is the one inactive
|
|
669
|
+
// state where a discovery hint is appropriate: an explicit opt-out
|
|
670
|
+
// (enabled:false, SPOR_ENABLED=0, mode:off) is a deliberate choice and must
|
|
671
|
+
// stay silent.
|
|
672
|
+
disabledByDefault() {
|
|
673
|
+
return (
|
|
674
|
+
this.get("mode", "auto") !== "off" &&
|
|
675
|
+
this.get("enabled", undefined) === undefined &&
|
|
676
|
+
!this._repoMarker
|
|
677
|
+
);
|
|
678
|
+
}
|
|
664
679
|
}
|
|
665
680
|
|
|
666
681
|
module.exports = { loadConfig, Config, DEFAULTS, DEFAULT_SERVER, ENV_MAP, repoMarkerGraph, repoMarkerOrg, repoMarkerPresent };
|