@holmes-lab/holmes-kit 0.1.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/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/bin/holmes-hook-antigravity.js +31 -0
- package/bin/holmes-kit.js +23 -0
- package/bin/holmes-mcp.js +34 -0
- package/bin/holmes-stop-antigravity.js +29 -0
- package/dist/.build-id +1 -0
- package/dist/holmes/cli/agents.js +168 -0
- package/dist/holmes/cli/doctor.js +625 -0
- package/dist/holmes/cli/gitignore-merge.js +84 -0
- package/dist/holmes/cli/governed-precondition.js +157 -0
- package/dist/holmes/cli/index.js +384 -0
- package/dist/holmes/cli/init.js +462 -0
- package/dist/holmes/cli/playbook-skills.js +711 -0
- package/dist/holmes/cli/roles-readme.js +134 -0
- package/dist/holmes/cli/settings-merge.js +122 -0
- package/dist/holmes/config/config.js +70 -0
- package/dist/holmes/context/bundler.js +114 -0
- package/dist/holmes/context/render.js +29 -0
- package/dist/holmes/context/tiers.js +110 -0
- package/dist/holmes/context/tokens.js +8 -0
- package/dist/holmes/cpg/cpg-scanner.js +213 -0
- package/dist/holmes/cpg/hash-cache.js +86 -0
- package/dist/holmes/cpg/language-parser-walk.js +917 -0
- package/dist/holmes/cpg/language-parser-worker.js +81 -0
- package/dist/holmes/cpg/language-parser.js +234 -0
- package/dist/holmes/cpg/scan-cache.js +108 -0
- package/dist/holmes/cpg/source-path.js +44 -0
- package/dist/holmes/cpg/test-files.js +84 -0
- package/dist/holmes/governance/constitution-debt.js +73 -0
- package/dist/holmes/governance/constitution-report.js +25 -0
- package/dist/holmes/governance/constitution.js +129 -0
- package/dist/holmes/governance/identity.js +30 -0
- package/dist/holmes/governance/ledger-lock.js +165 -0
- package/dist/holmes/governance/ledger-store.conformance.js +90 -0
- package/dist/holmes/governance/ledger-store.js +106 -0
- package/dist/holmes/governance/progress-ledger.js +83 -0
- package/dist/holmes/governance/provenance-chain.js +365 -0
- package/dist/holmes/governance/provenance-ledger.js +0 -0
- package/dist/holmes/governance/provenance-schema.js +47 -0
- package/dist/holmes/governance/replica-id.js +106 -0
- package/dist/holmes/governance/role-policy.js +137 -0
- package/dist/holmes/governance/trust-score.js +43 -0
- package/dist/holmes/guardrail/anchors.js +31 -0
- package/dist/holmes/guardrail/blind-spots.js +38 -0
- package/dist/holmes/guardrail/decision-ledger.js +107 -0
- package/dist/holmes/guardrail/executable-artifact.js +129 -0
- package/dist/holmes/guardrail/governance-history.js +101 -0
- package/dist/holmes/guardrail/phase.js +169 -0
- package/dist/holmes/guardrail/risk-classifier.js +450 -0
- package/dist/holmes/guardrail/risk-gate.js +160 -0
- package/dist/holmes/guardrail/risk-types.js +6 -0
- package/dist/holmes/guardrail/tspec-state.js +392 -0
- package/dist/holmes/guardrail/write-target.js +224 -0
- package/dist/holmes/hooks/adapters/antigravity.js +194 -0
- package/dist/holmes/hooks/pre-tool-use.js +1262 -0
- package/dist/holmes/hooks/stop.js +416 -0
- package/dist/holmes/mcp/basis.js +162 -0
- package/dist/holmes/mcp/handlers.js +1831 -0
- package/dist/holmes/mcp/server.js +71 -0
- package/dist/holmes/mcp/stdio-client.js +165 -0
- package/dist/holmes/mcp/supervisor.js +178 -0
- package/dist/holmes/mcp/tool-schemas.js +394 -0
- package/dist/holmes/mcp/validate-args.js +281 -0
- package/dist/holmes/messages/registry.js +50 -0
- package/dist/holmes/project/baseline.js +210 -0
- package/dist/holmes/project/change-source.js +233 -0
- package/dist/holmes/project/ignore.js +145 -0
- package/dist/holmes/project/root.js +113 -0
- package/dist/holmes/reverse/anchor.js +162 -0
- package/dist/holmes/reverse/cluster.js +187 -0
- package/dist/holmes/reverse/draft.js +151 -0
- package/dist/holmes/reverse/dynamic-wiring.js +47 -0
- package/dist/holmes/reverse/scan.js +194 -0
- package/dist/holmes/reverse/surface.js +154 -0
- package/dist/holmes/reverse/test-map.js +263 -0
- package/dist/holmes/review/coverage.js +33 -0
- package/dist/holmes/review/findings.js +123 -0
- package/dist/holmes/review/package.js +40 -0
- package/dist/holmes/review/review-targets.js +92 -0
- package/dist/holmes/review/scope.js +57 -0
- package/dist/holmes/review/test-evidence.js +77 -0
- package/dist/holmes/review/test-runner.js +572 -0
- package/dist/holmes/rtm/dataflow-taint.js +262 -0
- package/dist/holmes/rtm/gap-analyzer.js +27 -0
- package/dist/holmes/rtm/git-changes.js +72 -0
- package/dist/holmes/rtm/incremental.js +45 -0
- package/dist/holmes/rtm/localize.js +100 -0
- package/dist/holmes/rtm/rtm-builder.js +191 -0
- package/dist/holmes/rtm/rtm-check.js +89 -0
- package/dist/holmes/rtm/rtm-graph.js +232 -0
- package/dist/holmes/rtm/taint.js +92 -0
- package/dist/holmes/rtm/test-scope.js +336 -0
- package/dist/holmes/spec/approval-blockers.js +204 -0
- package/dist/holmes/spec/breaking-change.js +89 -0
- package/dist/holmes/spec/legacy-format.js +87 -0
- package/dist/holmes/spec/spec-digest.js +71 -0
- package/dist/holmes/spec/spec-parser.js +106 -0
- package/dist/holmes/spec/spec-store.conformance.js +118 -0
- package/dist/holmes/spec/spec-store.js +331 -0
- package/dist/holmes/spec/spec-types.js +177 -0
- package/dist/holmes/spec/validator.js +280 -0
- package/package.json +76 -0
- package/playbooks/adopt/PLAYBOOK.md +125 -0
- package/playbooks/author-slice/PLAYBOOK.md +119 -0
- package/playbooks/promote-slice/PLAYBOOK.md +134 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LEGACY_STATUS_FIELD = void 0;
|
|
4
|
+
exports.formatStateOf = formatStateOf;
|
|
5
|
+
exports.legacyMessage = legacyMessage;
|
|
6
|
+
exports.upgradePlan = upgradePlan;
|
|
7
|
+
exports.upgradedFrontmatter = upgradedFrontmatter;
|
|
8
|
+
const spec_types_1 = require("./spec-types");
|
|
9
|
+
/** Frontmatter key that preserves the pre-upgrade status. Named once so writer and reader agree. */
|
|
10
|
+
exports.LEGACY_STATUS_FIELD = 'legacy_status';
|
|
11
|
+
/**
|
|
12
|
+
* Classify a document by FORMAT, not by validity.
|
|
13
|
+
*
|
|
14
|
+
* A DECLARED type always wins over the id. Inference exists to help a document that never said what
|
|
15
|
+
* it was — never to overrule a human who did. A declared type that this tool does not have is NOT
|
|
16
|
+
* `current`: declaring `type: JOB` must not smuggle an unknown kind past the governed boundary.
|
|
17
|
+
*/
|
|
18
|
+
function formatStateOf(spec) {
|
|
19
|
+
const declared = (0, spec_types_1.specTypeDef)(spec.type);
|
|
20
|
+
if (declared)
|
|
21
|
+
return { kind: 'current', type: declared.type };
|
|
22
|
+
const inferred = (0, spec_types_1.specTypeOfId)(spec.id);
|
|
23
|
+
return inferred ? { kind: 'legacy', inferred } : { kind: 'unsupported' };
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Why this document cannot be used as a governed spec, and what to do about it — or `null` when the
|
|
27
|
+
* document is in current format and the caller's existing wording should stand.
|
|
28
|
+
*
|
|
29
|
+
* Returning `null` for `current` is what keeps this change from leaking into paths that were already
|
|
30
|
+
* correct: a parent of the wrong DECLARED kind was always reported accurately, and REQ-184 does not
|
|
31
|
+
* touch it.
|
|
32
|
+
*
|
|
33
|
+
* The two non-null cases must read differently. Telling the holder of a `JOB-001` to upgrade it
|
|
34
|
+
* would send them after a type that does not exist — the same "points nowhere" failure this REQ
|
|
35
|
+
* exists to close, merely relocated.
|
|
36
|
+
*/
|
|
37
|
+
function legacyMessage(spec) {
|
|
38
|
+
const state = formatStateOf(spec);
|
|
39
|
+
if (state.kind === 'current')
|
|
40
|
+
return null;
|
|
41
|
+
if (state.kind === 'unsupported') {
|
|
42
|
+
return (`${spec.id}은(는) 이 도구가 다루는 스펙 종류가 아닙니다 — id가 현행 종류 규약에 맞지 않습니다. ` +
|
|
43
|
+
'옛 시스템의 문서라면 그대로 두십시오. 거버넌스 대상이 아니며, 보강해도 쓸 수 있는 종류가 되지 않습니다.');
|
|
44
|
+
}
|
|
45
|
+
return (`${spec.id}에 \`type:\` 선언이 없습니다 — 현행보다 이전 양식으로 쓰인 문서입니다. ` +
|
|
46
|
+
`id로 보면 ${state.inferred}입니다. 이 문서를 거버넌스 아래에서 쓰려면 ` +
|
|
47
|
+
`\`spec_upgrade\`로 보강하십시오(선언만 더하며 본문과 승인 상태는 건드리지 않습니다).`);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* What an upgrade would change, or `null` when there is nothing to do.
|
|
51
|
+
*
|
|
52
|
+
* `null` for BOTH `current` and `unsupported`, for opposite reasons — already declared, or nothing
|
|
53
|
+
* to declare. The caller distinguishes them; this function only answers "is there a plan".
|
|
54
|
+
*
|
|
55
|
+
* The plan carries no status of its own on purpose. Upgrading declares a KIND; it does not decide
|
|
56
|
+
* whether the document is normative. 37 of this repository's legacy documents say `Approved`, and
|
|
57
|
+
* carrying that across would mint 37 approvals that never passed through the sealing act — no
|
|
58
|
+
* `approved_digest`, no `parent_digests`, no ledger entry. The old value is preserved as evidence
|
|
59
|
+
* and the document restarts at `draft`, from where the normal approval path can seal it properly.
|
|
60
|
+
*/
|
|
61
|
+
function upgradePlan(spec) {
|
|
62
|
+
const state = formatStateOf(spec);
|
|
63
|
+
if (state.kind !== 'legacy')
|
|
64
|
+
return null;
|
|
65
|
+
// Empty rather than `String(undefined)`. Unreachable through the store — the parser defaults a
|
|
66
|
+
// missing status to 'draft', and both measured corpora have zero such documents — but a bare
|
|
67
|
+
// `String()` here would write the literal `undefined` into a governance artefact permanently,
|
|
68
|
+
// which is the exact defect this REQ exists to remove. The caller omits the field when empty.
|
|
69
|
+
return { type: state.inferred, legacyStatus: spec.status ? String(spec.status) : '' };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The frontmatter an upgrade writes: everything that was there, plus the preserved status.
|
|
73
|
+
*
|
|
74
|
+
* Lives here rather than inline at the call site so the omission rule is exercisable. The store's
|
|
75
|
+
* parser defaults a missing status to `draft`, so a document with none cannot arrive through it —
|
|
76
|
+
* which is exactly why the rule would otherwise go untested and could be deleted unnoticed.
|
|
77
|
+
*
|
|
78
|
+
* The spread comes first so a document that somehow already carries `legacy_status` keeps it rather
|
|
79
|
+
* than having it rewritten from a status that is itself the result of an earlier upgrade.
|
|
80
|
+
*/
|
|
81
|
+
function upgradedFrontmatter(spec, plan) {
|
|
82
|
+
// Omitted, never written empty: `legacy_status:` with nothing after it asserts evidence that does
|
|
83
|
+
// not exist, and `String(undefined)` would put the literal `undefined` into a governance artefact.
|
|
84
|
+
if (!plan.legacyStatus)
|
|
85
|
+
return { ...spec.frontmatter };
|
|
86
|
+
return { ...spec.frontmatter, [exports.LEGACY_STATUS_FIELD]: plan.legacyStatus };
|
|
87
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SEAL_FIELDS = void 0;
|
|
4
|
+
exports.specDigest = specDigest;
|
|
5
|
+
exports.sealForTest = sealForTest;
|
|
6
|
+
exports.sealOf = sealOf;
|
|
7
|
+
const node_crypto_1 = require("node:crypto");
|
|
8
|
+
/**
|
|
9
|
+
* @implements A-SPEC-132
|
|
10
|
+
* The canonical content digest an approval SEALS. It covers what approving means — the spec's kind,
|
|
11
|
+
* its title, which parents it claims, and every section body — and deliberately excludes `status`
|
|
12
|
+
* and the seal fields themselves (a seal must not seal itself, and flipping status must not break
|
|
13
|
+
* the seal it accompanies). All other frontmatter is excluded too: owner/priority metadata churn is
|
|
14
|
+
* not a content change someone must re-approve.
|
|
15
|
+
*
|
|
16
|
+
* Canonicalization makes the digest platform- and formatting-stable: `dependsOn` is sorted, section
|
|
17
|
+
* keys are sorted, section bodies are LF-normalized and right-trimmed. Same content, same hex —
|
|
18
|
+
* reordering YAML keys or a CRLF checkout cannot fake a post-approval edit.
|
|
19
|
+
*/
|
|
20
|
+
function specDigest(spec) {
|
|
21
|
+
// @implements A-SPEC-146
|
|
22
|
+
// `breaking_change` is sealed, CONDITIONALLY. Adversarial review found the hole: specDigest hashes
|
|
23
|
+
// only type/title/dependsOn/sections, so an approved A-SPEC could have its declaration deleted with
|
|
24
|
+
// the seal still matching — the enforcement could be stripped after the fact, and REQ-146's claim
|
|
25
|
+
// that "the seal already catches this" was asserted without measuring and was false.
|
|
26
|
+
//
|
|
27
|
+
// Conditional, not unconditional: adding the key for every spec would change all 38 existing seals
|
|
28
|
+
// at once, flooding `post-approval-edit` and blocking the Stop hook every turn — the same bricking
|
|
29
|
+
// this requirement measured and avoided elsewhere. A spec that never had the field keeps its digest;
|
|
30
|
+
// one that has it is bound to it, so removal or alteration surfaces as drift.
|
|
31
|
+
const bc = spec.frontmatter?.breaking_change;
|
|
32
|
+
const canonical = JSON.stringify({
|
|
33
|
+
type: spec.type,
|
|
34
|
+
title: spec.title,
|
|
35
|
+
dependsOn: [...spec.dependsOn].sort(),
|
|
36
|
+
sections: Object.keys(spec.sections).sort().map((k) => [k, spec.sections[k].replace(/\r\n/g, '\n').trimEnd()]),
|
|
37
|
+
...(spec.type === 'A-SPEC' && typeof bc === 'string' ? { breaking_change: bc } : {}),
|
|
38
|
+
});
|
|
39
|
+
return `sha256:${(0, node_crypto_1.createHash)('sha256').update(canonical, 'utf8').digest('hex')}`;
|
|
40
|
+
}
|
|
41
|
+
/** Frontmatter keys written only by an approval act (spec_approve / the one-time backfill). */
|
|
42
|
+
exports.SEAL_FIELDS = ['approved_digest', 'parent_digests'];
|
|
43
|
+
/**
|
|
44
|
+
* @implements A-SPEC-132
|
|
45
|
+
* Test-only helper: return a copy of `spec` sealed at its current content, snapshotting each given
|
|
46
|
+
* parent's own seal. Fixtures that represent an APPROVED spec must now carry a seal to be valid — the
|
|
47
|
+
* validator enforces it — so tests build approved fixtures through this. Not for production writes:
|
|
48
|
+
* spec_approve is the only real sealer (it also validates and ledgers).
|
|
49
|
+
*/
|
|
50
|
+
function sealForTest(spec, parents = []) {
|
|
51
|
+
const parent_digests = {};
|
|
52
|
+
for (const p of parents) {
|
|
53
|
+
const d = sealOf(p).approvedDigest;
|
|
54
|
+
if (d)
|
|
55
|
+
parent_digests[p.id] = d;
|
|
56
|
+
}
|
|
57
|
+
return { ...spec, frontmatter: { ...spec.frontmatter, approved_digest: specDigest(spec), parent_digests } };
|
|
58
|
+
}
|
|
59
|
+
/** The seal a spec carries, if any. */
|
|
60
|
+
function sealOf(spec) {
|
|
61
|
+
const approvedDigest = typeof spec.frontmatter.approved_digest === 'string' ? spec.frontmatter.approved_digest : undefined;
|
|
62
|
+
const raw = spec.frontmatter.parent_digests;
|
|
63
|
+
const parentDigests = {};
|
|
64
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
65
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
66
|
+
if (typeof v === 'string')
|
|
67
|
+
parentDigests[k] = v;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { approvedDigest, parentDigests };
|
|
71
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.parseSpec = parseSpec;
|
|
37
|
+
exports.serializeSpec = serializeSpec;
|
|
38
|
+
const yaml = __importStar(require("js-yaml"));
|
|
39
|
+
function splitSections(body) {
|
|
40
|
+
const out = {};
|
|
41
|
+
const lines = body.split('\n');
|
|
42
|
+
let inFence = false;
|
|
43
|
+
let currentHeading = null;
|
|
44
|
+
let currentContent = [];
|
|
45
|
+
const headingRegex = /^##\s+(.+?)\s*$/;
|
|
46
|
+
for (const line of lines) {
|
|
47
|
+
// Toggle fence state on lines matching /^\s*```/
|
|
48
|
+
if (/^\s*```/.test(line)) {
|
|
49
|
+
inFence = !inFence;
|
|
50
|
+
if (currentHeading !== null)
|
|
51
|
+
currentContent.push(line);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
// Check for section heading only when not in fence
|
|
55
|
+
const headingMatch = !inFence ? headingRegex.exec(line) : null;
|
|
56
|
+
if (headingMatch) {
|
|
57
|
+
// Save previous section if exists
|
|
58
|
+
if (currentHeading !== null) {
|
|
59
|
+
out[currentHeading] = currentContent.join('\n').trim();
|
|
60
|
+
}
|
|
61
|
+
// Start new section
|
|
62
|
+
currentHeading = headingMatch[1].trim();
|
|
63
|
+
currentContent = [];
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
// Add line to current section
|
|
67
|
+
if (currentHeading !== null) {
|
|
68
|
+
currentContent.push(line);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Save final section
|
|
73
|
+
if (currentHeading !== null) {
|
|
74
|
+
out[currentHeading] = currentContent.join('\n').trim();
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
function parseSpec(input) {
|
|
79
|
+
// @implements A-SPEC-176
|
|
80
|
+
// Fold CRLF at the door, once. Measured 2026-08-12: the delimiter below is LF-only, so a spec saved
|
|
81
|
+
// by a Windows editor (or cloned with core.autocrlf=true) parsed to `id: ""` WITHOUT throwing, and
|
|
82
|
+
// `list()` dropped it — the user saw an approved document on disk while the gate said it was not
|
|
83
|
+
// approved. Fixing only the delimiter would leave a `\r` on every frontmatter value instead, which
|
|
84
|
+
// poisons ids and titles silently rather than failing.
|
|
85
|
+
//
|
|
86
|
+
// Safe for every existing seal: on an LF document this replace is the identity, so the approval
|
|
87
|
+
// digests of the 400+ specs already in this repository are unchanged (ADR-013 tier ① avoided).
|
|
88
|
+
const raw = input.replace(/\r\n/g, '\n');
|
|
89
|
+
const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(raw);
|
|
90
|
+
const fm = ((m ? yaml.load(m[1]) : {}) ?? {});
|
|
91
|
+
const body = m ? m[2] : raw;
|
|
92
|
+
return {
|
|
93
|
+
id: String(fm.id ?? ''),
|
|
94
|
+
type: fm.type,
|
|
95
|
+
title: String(fm.title ?? ''),
|
|
96
|
+
status: (fm.status ?? 'draft'),
|
|
97
|
+
dependsOn: Array.isArray(fm.depends_on) ? fm.depends_on : [],
|
|
98
|
+
frontmatter: fm,
|
|
99
|
+
sections: splitSections(body),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function serializeSpec(spec) {
|
|
103
|
+
const fm = { ...spec.frontmatter, id: spec.id, type: spec.type, title: spec.title, status: spec.status, depends_on: spec.dependsOn };
|
|
104
|
+
const body = Object.entries(spec.sections).map(([h, t]) => `## ${h}\n${t}\n`).join('\n');
|
|
105
|
+
return `---\n${yaml.dump(fm)}---\n\n${body}`;
|
|
106
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.specStoreConformance = specStoreConformance;
|
|
4
|
+
// @implements A-SPEC-151
|
|
5
|
+
const spec_store_1 = require("./spec-store");
|
|
6
|
+
/**
|
|
7
|
+
* The ONE conformance suite every `SpecStore` adapter must pass.
|
|
8
|
+
*
|
|
9
|
+
* Written entirely against the CONTRACT — no paths, no files, no knowledge of how anything is
|
|
10
|
+
* stored. ADR-014 recorded the limit measured on the ledger port: a storage-agnostic suite cannot
|
|
11
|
+
* see storage-specific defects, so each adapter still owes tests about its own medium. What belongs
|
|
12
|
+
* HERE is only what is meaningful for every adapter.
|
|
13
|
+
*/
|
|
14
|
+
function specStoreConformance(name, make) {
|
|
15
|
+
const req = (over = {}, fm = {}) => ({
|
|
16
|
+
id: 'REQ-900', type: 'REQ', title: 't', status: 'draft', dependsOn: [],
|
|
17
|
+
sections: {
|
|
18
|
+
'Problem / Need': 'x', 'Desired Outcome': 'y', Constraints: 'c',
|
|
19
|
+
'Success Criteria': 's', 'Out of Scope': 'o',
|
|
20
|
+
},
|
|
21
|
+
frontmatter: {
|
|
22
|
+
source: [{ kind: 'user-request', ref: 'r', retrieved: '2026-08-07' }],
|
|
23
|
+
created: '2026-08-07', ...fm,
|
|
24
|
+
},
|
|
25
|
+
...over,
|
|
26
|
+
});
|
|
27
|
+
describe(`SpecStore conformance — ${name} (A-SPEC-151)`, () => {
|
|
28
|
+
it('normal: what is written can be read back', async () => {
|
|
29
|
+
const s = make();
|
|
30
|
+
await s.write(req());
|
|
31
|
+
const got = await s.read('REQ-900');
|
|
32
|
+
expect(got?.spec.id).toBe('REQ-900');
|
|
33
|
+
expect(got?.spec.title).toBe('t');
|
|
34
|
+
});
|
|
35
|
+
it('normal: read returns a version alongside the spec', async () => {
|
|
36
|
+
const s = make();
|
|
37
|
+
await s.write(req());
|
|
38
|
+
expect(typeof (await s.read('REQ-900'))?.version).toBe('string');
|
|
39
|
+
expect((await s.read('REQ-900'))?.version).toBeTruthy();
|
|
40
|
+
});
|
|
41
|
+
it('boundary: an unknown id reads as null', async () => {
|
|
42
|
+
expect(await make().read('REQ-999')).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
it('boundary: the same content yields the same version — the token must be deterministic', async () => {
|
|
45
|
+
const s = make();
|
|
46
|
+
await s.write(req());
|
|
47
|
+
const first = (await s.read('REQ-900')).version;
|
|
48
|
+
await s.write(req()); // identical content, written again
|
|
49
|
+
expect((await s.read('REQ-900')).version).toBe(first);
|
|
50
|
+
});
|
|
51
|
+
it('corner: changed CONTENT changes the version', async () => {
|
|
52
|
+
const s = make();
|
|
53
|
+
await s.write(req());
|
|
54
|
+
const before = (await s.read('REQ-900')).version;
|
|
55
|
+
await s.write(req({ title: 'changed' }));
|
|
56
|
+
expect((await s.read('REQ-900')).version).not.toBe(before);
|
|
57
|
+
});
|
|
58
|
+
it('corner: a change to STATUS ALONE changes the version', async () => {
|
|
59
|
+
// Why `specDigest` cannot serve as this token: it hashes type/title/dependsOn/sections and
|
|
60
|
+
// deliberately excludes status, so a draft→approved flip would be invisible to it. The two
|
|
61
|
+
// digests answer different questions — "is this the content that was approved" versus "has
|
|
62
|
+
// anyone touched this since I read it" — and collapsing them would weaken both.
|
|
63
|
+
const s = make();
|
|
64
|
+
await s.write(req());
|
|
65
|
+
const before = (await s.read('REQ-900')).version;
|
|
66
|
+
await s.write(req({ status: 'approved' }));
|
|
67
|
+
expect((await s.read('REQ-900')).version).not.toBe(before);
|
|
68
|
+
});
|
|
69
|
+
it('normal: writing with the CURRENT version succeeds and returns the new one', async () => {
|
|
70
|
+
const s = make();
|
|
71
|
+
await s.write(req());
|
|
72
|
+
const v = (await s.read('REQ-900')).version;
|
|
73
|
+
const r = await s.write(req({ title: 'next' }), { expectedVersion: v });
|
|
74
|
+
expect(r.version).toBeTruthy();
|
|
75
|
+
expect((await s.read('REQ-900')).spec.title).toBe('next');
|
|
76
|
+
});
|
|
77
|
+
it('normal: writing with NO expectedVersion still succeeds — existing callers are unaffected', async () => {
|
|
78
|
+
const s = make();
|
|
79
|
+
await s.write(req());
|
|
80
|
+
await expect(s.write(req({ title: 'blind' }))).resolves.toBeTruthy();
|
|
81
|
+
expect((await s.read('REQ-900')).spec.title).toBe('blind');
|
|
82
|
+
});
|
|
83
|
+
it('negative: a STALE expectedVersion is rejected AND leaves the stored spec untouched', async () => {
|
|
84
|
+
// A refusal that half-wrote would be the worst of both: the caller is told it failed while the
|
|
85
|
+
// store has already changed.
|
|
86
|
+
const s = make();
|
|
87
|
+
await s.write(req());
|
|
88
|
+
const stale = (await s.read('REQ-900')).version;
|
|
89
|
+
await s.write(req({ title: 'someone else' })); // concurrent change
|
|
90
|
+
await expect(s.write(req({ title: 'mine' }), { expectedVersion: stale }))
|
|
91
|
+
.rejects.toBeInstanceOf(spec_store_1.SpecVersionConflictError);
|
|
92
|
+
expect((await s.read('REQ-900')).spec.title).toBe('someone else');
|
|
93
|
+
});
|
|
94
|
+
it('negative: expectedVersion on a spec that does not exist is a conflict', async () => {
|
|
95
|
+
// "Is what I read still there" cannot be satisfied by "there is no such thing".
|
|
96
|
+
await expect(make().write(req(), { expectedVersion: 'whatever' }))
|
|
97
|
+
.rejects.toBeInstanceOf(spec_store_1.SpecVersionConflictError);
|
|
98
|
+
});
|
|
99
|
+
it('normal: externalId round-trips — a mirrored spec keeps its foreign key', async () => {
|
|
100
|
+
const s = make();
|
|
101
|
+
await s.write(req({}, { external_id: 'JIRA-1234' }));
|
|
102
|
+
expect((await s.read('REQ-900')).spec.frontmatter.external_id).toBe('JIRA-1234');
|
|
103
|
+
});
|
|
104
|
+
it('boundary: a spec with no externalId reads back without one', async () => {
|
|
105
|
+
const s = make();
|
|
106
|
+
await s.write(req());
|
|
107
|
+
expect((await s.read('REQ-900')).spec.frontmatter.external_id).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
it('boundary: an empty store lists nothing and does not throw', async () => {
|
|
110
|
+
expect(await make().list()).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
it('normal: list includes what was written', async () => {
|
|
113
|
+
const s = make();
|
|
114
|
+
await s.write(req());
|
|
115
|
+
expect((await s.list()).map((x) => x.id)).toContain('REQ-900');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|