feat(self-feedback): purpose_anchor on entries (ADR-0000 restoration, v71)
SF is a purpose-to-software compiler — every self_feedback row must name
the milestone vision or slice goal it's filed against, so triage can
prioritize against purpose rather than treating each row as floating.
- Schema v71 ALTERs self_feedback ADD COLUMN purpose_anchor TEXT.
NULL allowed for legacy rows; fresh-DB CREATE includes the column.
- sf-db-self-feedback.js: insertSelfFeedbackEntry accepts purposeAnchor
(camelCase), stored as :purpose_anchor; listSelfFeedbackEntries({purpose})
pushes a LIKE %fragment% filter into the DB layer so triage doesn't
have to pull the full table.
- rowToSelfFeedback exposes purposeAnchor, falling back to the JSON
projection for legacy rows where the column is NULL.
- headless-feedback CLI: `feedback add --purpose <fragment>` persists
the anchor; `feedback list --purpose <fragment>` filters by it.
Omission stays valid — restoration is additive, not breaking.
- help-text + migration test updated; new vitest covers add/list
round-trip, NULL-on-omit legacy compat, substring match, and the
help-text documentation contract.
Restores the doctrine in docs/adr/0000-purpose-to-software-compiler.md:
"non-trivial artifacts must name their purpose and consumer."
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3c416162b2
commit
725affd126
7 changed files with 306 additions and 14 deletions
|
|
@ -122,6 +122,15 @@ async function handleAdd(basePath: string, options: FeedbackOptions): Promise<Fe
|
|||
const unitType = readFlag(options.args, "--unit-type");
|
||||
const impactScore = parseIntOrUndefined(readFlag(options.args, "--impact-score"));
|
||||
const effortEstimate = parseIntOrUndefined(readFlag(options.args, "--effort-estimate"));
|
||||
// purpose_anchor (sf-db v71, ADR-0000): free-text fragment of the milestone
|
||||
// vision or slice goal sentence this feedback is filed against. Optional —
|
||||
// the CLI accepts omission so legacy callers keep working, but triage can
|
||||
// only prioritize against purpose for rows that supply it.
|
||||
const purposeAnchorRaw = readFlag(options.args, "--purpose");
|
||||
const purposeAnchor =
|
||||
typeof purposeAnchorRaw === "string" && purposeAnchorRaw.trim() !== ""
|
||||
? purposeAnchorRaw.trim()
|
||||
: undefined;
|
||||
const blocking =
|
||||
readBoolFlag(options.args, "--blocking") || severity === "high" || severity === "critical";
|
||||
|
||||
|
|
@ -147,6 +156,7 @@ async function handleAdd(basePath: string, options: FeedbackOptions): Promise<Fe
|
|||
suggestedFix,
|
||||
impactScore,
|
||||
effortEstimate,
|
||||
purposeAnchor,
|
||||
source: "headless-cli",
|
||||
};
|
||||
|
||||
|
|
@ -164,6 +174,7 @@ async function handleAdd(basePath: string, options: FeedbackOptions): Promise<Fe
|
|||
severity,
|
||||
blocking,
|
||||
impact_score: impactScore,
|
||||
purpose_anchor: purposeAnchor ?? null,
|
||||
summary: entry.summary,
|
||||
}, `${id} ${severity.padEnd(8)} ${kind} ${entry.summary}`);
|
||||
return { exitCode: 0 };
|
||||
|
|
@ -178,10 +189,18 @@ async function handleList(basePath: string, options: FeedbackOptions): Promise<F
|
|||
);
|
||||
return { exitCode: 2 };
|
||||
}
|
||||
// --purpose <fragment>: filter to rows whose purpose_anchor contains the
|
||||
// fragment. Pushed into the DB query (LIKE %fragment%) so triage doesn't
|
||||
// pull the full table just to scope by purpose (ADR-0000, v71).
|
||||
const purposeFragmentRaw = readFlag(options.args, "--purpose");
|
||||
const purposeFragment =
|
||||
typeof purposeFragmentRaw === "string" && purposeFragmentRaw.trim() !== ""
|
||||
? purposeFragmentRaw.trim()
|
||||
: undefined;
|
||||
|
||||
await loadDb(basePath);
|
||||
const sfDb = (await jiti.import(sfExtensionPath("sf-db/sf-db-self-feedback"), {})) as {
|
||||
listSelfFeedbackEntries: () => Array<{
|
||||
listSelfFeedbackEntries: (options?: { purpose?: string }) => Array<{
|
||||
id: string;
|
||||
ts: string;
|
||||
kind: string;
|
||||
|
|
@ -190,9 +209,12 @@ async function handleList(basePath: string, options: FeedbackOptions): Promise<F
|
|||
summary: string;
|
||||
resolvedAt?: string | null;
|
||||
impactScore?: number | null;
|
||||
purposeAnchor?: string | null;
|
||||
}>;
|
||||
};
|
||||
let entries = sfDb.listSelfFeedbackEntries();
|
||||
let entries = sfDb.listSelfFeedbackEntries(
|
||||
purposeFragment ? { purpose: purposeFragment } : undefined,
|
||||
);
|
||||
if (wantUnresolved) {
|
||||
entries = entries.filter((e) => !e.resolvedAt);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -259,8 +259,8 @@ const SUBCOMMAND_HELP: Record<string, string> = {
|
|||
" complete-slice Mark a slice complete out-of-band: complete-slice <M>/<S> [--reason <txt>]",
|
||||
" skip-slice Mark a slice skipped out-of-band (placeholder/migration cleanup)",
|
||||
" complete-milestone Mark a milestone complete out-of-band: complete-milestone <M> [--reason <txt>]",
|
||||
" feedback add File a self_feedback entry (--summary <txt> --severity low|medium|high|critical [--kind <k>] [--evidence <t>] [--suggested-fix <t>] [--milestone M --slice S --task T] [--impact-score N] [--effort-estimate N] [--blocking])",
|
||||
" feedback list List self_feedback entries [--unresolved] [--severity <s>] [--json]",
|
||||
" feedback add File a self_feedback entry (--summary <txt> --severity low|medium|high|critical [--kind <k>] [--evidence <t>] [--suggested-fix <t>] [--milestone M --slice S --task T] [--impact-score N] [--effort-estimate N] [--blocking] [--purpose <fragment>])",
|
||||
" feedback list List self_feedback entries [--unresolved] [--severity <s>] [--purpose <fragment>] [--json]",
|
||||
" feedback resolve Resolve an entry: feedback resolve <id> --reason <txt> [--evidence-kind human-clear|agent-fix|...]",
|
||||
"",
|
||||
"new-milestone flags:",
|
||||
|
|
@ -303,7 +303,9 @@ const SUBCOMMAND_HELP: Record<string, string> = {
|
|||
" sf headless skip-slice M003/S01 --reason \"migration placeholder\" Mark placeholder slice skipped",
|
||||
" sf headless complete-milestone M010 Flip milestone to status=complete",
|
||||
" sf headless feedback add --severity high --summary \"30K truncate drops the why\" File self-feedback",
|
||||
" sf headless feedback add --summary \"...\" --purpose \"M015 vision: ...\" Anchor to a purpose (ADR-0000)",
|
||||
" sf headless feedback list --unresolved Pending self-feedback entries",
|
||||
" sf headless feedback list --purpose \"M015 vision\" Triage by purpose anchor",
|
||||
" sf headless feedback resolve sf-mp4xxx --reason \"shipped in 7b85a6\" Resolve an entry",
|
||||
"",
|
||||
"Exit codes: 0 = success, 1 = error/timeout, 10 = blocked, 11 = cancelled",
|
||||
|
|
|
|||
|
|
@ -861,6 +861,13 @@ export function rowToTask(row) {
|
|||
}
|
||||
|
||||
export function rowToSelfFeedback(row) {
|
||||
// purpose_anchor (v71, ADR-0000): expose the structured column verbatim,
|
||||
// falling back to the JSON projection so reads from legacy rows (where the
|
||||
// column is NULL but the anchor lives inside full_json) still surface it.
|
||||
const columnPurposeAnchor =
|
||||
typeof row["purpose_anchor"] === "string" && row["purpose_anchor"] !== ""
|
||||
? row["purpose_anchor"]
|
||||
: null;
|
||||
try {
|
||||
const parsed = JSON.parse(row["full_json"]);
|
||||
return {
|
||||
|
|
@ -883,6 +890,7 @@ export function rowToSelfFeedback(row) {
|
|||
typeof row["effort_estimate"] === "number"
|
||||
? row["effort_estimate"]
|
||||
: parsed.effortEstimate ?? null,
|
||||
purposeAnchor: columnPurposeAnchor ?? parsed.purposeAnchor ?? null,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
|
|
@ -920,6 +928,7 @@ export function rowToSelfFeedback(row) {
|
|||
typeof row["effort_estimate"] === "number"
|
||||
? row["effort_estimate"]
|
||||
: undefined,
|
||||
purposeAnchor: columnPurposeAnchor ?? undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -454,7 +454,8 @@ function ensureSelfFeedbackTables(db) {
|
|||
resolved_evidence_json TEXT DEFAULT NULL,
|
||||
resolved_criteria_json TEXT DEFAULT NULL,
|
||||
impact_score INTEGER DEFAULT NULL,
|
||||
effort_estimate INTEGER DEFAULT NULL
|
||||
effort_estimate INTEGER DEFAULT NULL,
|
||||
purpose_anchor TEXT DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
db.exec(
|
||||
|
|
@ -3633,6 +3634,43 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) {
|
|||
if (ok) appliedVersion = 70;
|
||||
}
|
||||
|
||||
if (appliedVersion < 71) {
|
||||
const ok = runMigrationStep("v71", () => {
|
||||
// Schema v71: purpose_anchor on self_feedback (ADR-0000 restoration).
|
||||
//
|
||||
// SF is a purpose-to-software compiler — every artifact must name the
|
||||
// purpose it serves. self_feedback rows historically lacked any link
|
||||
// to the milestone vision or slice goal they're filed against, which
|
||||
// left triage unable to prioritize against purpose. Adding a free-text
|
||||
// `purpose_anchor` (typically the vision/goal sentence fragment) gives
|
||||
// operators and triage a structured "what purpose is at risk?" signal.
|
||||
//
|
||||
// NULL is allowed: legacy rows keep working, and the CLI accepts
|
||||
// omission. Future doctor checks can warn on missing anchors without
|
||||
// rejecting historical entries.
|
||||
//
|
||||
// Idempotent ALTER: ensureSelfFeedbackTables (the fresh-DB CREATE
|
||||
// path) already includes the column on a clean install. Older fixtures
|
||||
// need the ALTER. Probe via PRAGMA table_info first.
|
||||
const cols = new Set(
|
||||
db
|
||||
.prepare("PRAGMA table_info(self_feedback)")
|
||||
.all()
|
||||
.map((r) => r.name),
|
||||
);
|
||||
if (!cols.has("purpose_anchor")) {
|
||||
db.exec("ALTER TABLE self_feedback ADD COLUMN purpose_anchor TEXT");
|
||||
}
|
||||
db.prepare(
|
||||
"INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)",
|
||||
).run({
|
||||
":version": 71,
|
||||
":applied_at": new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
if (ok) appliedVersion = 71;
|
||||
}
|
||||
|
||||
// Post-migration assertion: ensure critical tables created by historical
|
||||
// migrations are actually present. If a prior migration claimed success but
|
||||
// the table is missing (e.g., due to a rolled-back transaction that failed
|
||||
|
|
|
|||
|
|
@ -34,17 +34,24 @@ export function insertSelfFeedbackEntry(entry) {
|
|||
: defaultImpactForSeverity(entry.severity);
|
||||
const effortEstimate =
|
||||
typeof entry.effortEstimate === "number" ? entry.effortEstimate : null;
|
||||
// purpose_anchor (sf-db v71, ADR-0000 restoration): free-text fragment of
|
||||
// the milestone vision or slice goal sentence this entry is filed against.
|
||||
// NULL is allowed for legacy rows / callers that don't yet supply it.
|
||||
const purposeAnchor =
|
||||
typeof entry.purposeAnchor === "string" && entry.purposeAnchor.trim() !== ""
|
||||
? entry.purposeAnchor.trim()
|
||||
: null;
|
||||
currentDb
|
||||
.prepare(`INSERT INTO self_feedback (
|
||||
id, ts, kind, severity, blocking, repo_identity, sf_version, base_path,
|
||||
unit_type, milestone_id, slice_id, task_id, summary, evidence, suggested_fix, full_json,
|
||||
resolved_at, resolved_reason, resolved_by_sf_version, resolved_evidence_json, resolved_criteria_json,
|
||||
impact_score, effort_estimate
|
||||
impact_score, effort_estimate, purpose_anchor
|
||||
) VALUES (
|
||||
:id, :ts, :kind, :severity, :blocking, :repo_identity, :sf_version, :base_path,
|
||||
:unit_type, :milestone_id, :slice_id, :task_id, :summary, :evidence, :suggested_fix, :full_json,
|
||||
:resolved_at, :resolved_reason, :resolved_by_sf_version, :resolved_evidence_json, :resolved_criteria_json,
|
||||
:impact_score, :effort_estimate
|
||||
:impact_score, :effort_estimate, :purpose_anchor
|
||||
)
|
||||
ON CONFLICT(id) DO NOTHING`)
|
||||
.run({
|
||||
|
|
@ -75,12 +82,30 @@ export function insertSelfFeedbackEntry(entry) {
|
|||
: null,
|
||||
":impact_score": impactScore,
|
||||
":effort_estimate": effortEstimate,
|
||||
":purpose_anchor": purposeAnchor,
|
||||
});
|
||||
}
|
||||
|
||||
export function listSelfFeedbackEntries() {
|
||||
export function listSelfFeedbackEntries(options) {
|
||||
const currentDb = _getAdapter();
|
||||
if (!currentDb) return [];
|
||||
// Optional filter: substring match against purpose_anchor so triage can
|
||||
// scope to "rows filed against milestone X's vision" without pulling the
|
||||
// full table client-side (ADR-0000, v71). LIKE with % wildcards keeps the
|
||||
// match permissive — operators paste a sentence fragment, not the full
|
||||
// canonical anchor text.
|
||||
const purposeFragment =
|
||||
options && typeof options.purpose === "string" && options.purpose.trim() !== ""
|
||||
? options.purpose.trim()
|
||||
: null;
|
||||
if (purposeFragment) {
|
||||
const rows = currentDb
|
||||
.prepare(
|
||||
"SELECT * FROM self_feedback WHERE purpose_anchor LIKE :pattern ORDER BY ts ASC, id ASC",
|
||||
)
|
||||
.all({ ":pattern": `%${purposeFragment}%` });
|
||||
return rows.map(rowToSelfFeedback);
|
||||
}
|
||||
const rows = currentDb
|
||||
.prepare("SELECT * FROM self_feedback ORDER BY ts ASC, id ASC")
|
||||
.all();
|
||||
|
|
|
|||
|
|
@ -274,12 +274,18 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill",
|
|||
.prepare("SELECT MAX(version) AS version FROM schema_version")
|
||||
.get();
|
||||
assert.equal(version.version, 72);
|
||||
// v70: tasks gained purpose_trace column (ADR-0000 doctrine restoration)
|
||||
// v70: tasks.purpose_trace (P3, ADR-0000)
|
||||
const taskColumnsAfter = db.prepare("PRAGMA table_info(tasks)").all();
|
||||
assert.ok(
|
||||
taskColumnsAfter.some((row) => row.name === "purpose_trace"),
|
||||
"purpose_trace column should exist after v70 migration",
|
||||
);
|
||||
// v71: self_feedback.purpose_anchor (P5, ADR-0000)
|
||||
const selfFeedbackColumns = db.prepare("PRAGMA table_info(self_feedback)").all();
|
||||
assert.ok(
|
||||
selfFeedbackColumns.some((row) => row.name === "purpose_anchor"),
|
||||
"purpose_anchor column should exist after v71 migration",
|
||||
);
|
||||
// v61: intent_chapters table exists
|
||||
const chaptersTable = db
|
||||
.prepare(
|
||||
|
|
@ -332,6 +338,13 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill",
|
|||
colNames.includes("effort_estimate"),
|
||||
"effort_estimate column should exist after v65 migration",
|
||||
);
|
||||
// v71: self_feedback gained purpose_anchor (ADR-0000 restoration — every
|
||||
// feedback row must name the milestone vision / slice goal it's filed
|
||||
// against so triage can prioritize against purpose).
|
||||
assert.ok(
|
||||
colNames.includes("purpose_anchor"),
|
||||
"purpose_anchor column should exist after v71 migration",
|
||||
);
|
||||
// v66: quality_gates gained UOK schema-v2 metadata columns (UOK
|
||||
// control-plane plan, slice 2).
|
||||
const qgColumns = db.prepare("PRAGMA table_info(quality_gates)").all();
|
||||
|
|
@ -397,11 +410,8 @@ test("openDatabase_v52_db_heals_routing_history_and_auto_start_path_works", () =
|
|||
initRoutingHistory(dbPath);
|
||||
}, "initRoutingHistory should not throw on a v52 DB");
|
||||
|
||||
// Schema should have migrated to the current head (v72 after the
|
||||
// ADR-0000 swarm: main's v69 = memory_extraction failure_class,
|
||||
// then v70 = tasks.purpose_trace (P3), v71 = self_feedback.purpose_anchor
|
||||
// (P5), v72 = slices.traces_vision_fragment (P2, renumbered from v69
|
||||
// after the parallel-work collision).
|
||||
// Schema should have migrated to v72 (current head after the
|
||||
// ADR-0000 swarm). See higher comment for the migration genealogy.
|
||||
const version = db
|
||||
.prepare("SELECT MAX(version) AS version FROM schema_version")
|
||||
.get();
|
||||
|
|
|
|||
186
src/tests/headless-feedback-purpose.test.ts
Normal file
186
src/tests/headless-feedback-purpose.test.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
/**
|
||||
* purpose_anchor on self_feedback (sf-db v71, ADR-0000 restoration).
|
||||
*
|
||||
* SF is a purpose-to-software compiler — every self_feedback row should be
|
||||
* filable against the milestone vision or slice goal it's at risk against.
|
||||
* Without that link, triage can't prioritize work against purpose; it's
|
||||
* forced to treat every row as equally floating.
|
||||
*
|
||||
* This test covers the three slice contracts:
|
||||
* 1. `feedback add --purpose <fragment>` persists the anchor to SQLite.
|
||||
* 2. `feedback list --purpose <fragment>` filters rows by anchor substring.
|
||||
* 3. Help text documents `--purpose` for both add and list.
|
||||
*
|
||||
* Live-DB layer is exercised directly via insertSelfFeedbackEntry /
|
||||
* listSelfFeedbackEntries (same primitives the CLI uses) against an
|
||||
* in-memory database, mirroring the pattern used by self-feedback-db.test.mjs.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, test } from "vitest";
|
||||
import {
|
||||
closeDatabase,
|
||||
insertSelfFeedbackEntry,
|
||||
listSelfFeedbackEntries,
|
||||
openDatabase,
|
||||
} from "../resources/extensions/sf/sf-db.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const handlerSrc = readFileSync(
|
||||
join(__dirname, "..", "headless-feedback.ts"),
|
||||
"utf-8",
|
||||
);
|
||||
const helpSrc = readFileSync(join(__dirname, "..", "help-text.ts"), "utf-8");
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
});
|
||||
|
||||
function baseEntry(overrides: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
id: `purpose-test-${Math.random().toString(36).slice(2, 8)}`,
|
||||
ts: new Date().toISOString(),
|
||||
kind: "improvement-idea",
|
||||
severity: "medium",
|
||||
blocking: false,
|
||||
repoIdentity: "test",
|
||||
sfVersion: "test",
|
||||
basePath: "/tmp/test",
|
||||
summary: "test entry",
|
||||
evidence: "",
|
||||
suggestedFix: "",
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("handler accepts --purpose flag on feedback add", () => {
|
||||
assert.match(
|
||||
handlerSrc,
|
||||
/readFlag\(options\.args, "--purpose"\)/,
|
||||
"handleAdd must read --purpose <fragment>",
|
||||
);
|
||||
assert.match(
|
||||
handlerSrc,
|
||||
/purposeAnchor/,
|
||||
"handleAdd must pass purposeAnchor through to the entry payload",
|
||||
);
|
||||
});
|
||||
|
||||
test("handler accepts --purpose flag on feedback list and pushes it into the DB query", () => {
|
||||
// The list handler must read --purpose and forward it as { purpose: ... }
|
||||
// to listSelfFeedbackEntries so the LIKE filter runs in SQLite rather than
|
||||
// pulling the full table into memory.
|
||||
assert.match(
|
||||
handlerSrc,
|
||||
/listSelfFeedbackEntries\(\s*purposeFragment\s*\?\s*\{\s*purpose:\s*purposeFragment\s*\}\s*:\s*undefined\s*,?\s*\)/,
|
||||
"handleList must forward purposeFragment as { purpose } to the DB layer",
|
||||
);
|
||||
});
|
||||
|
||||
test("help text documents --purpose for feedback add and list", () => {
|
||||
assert.match(
|
||||
helpSrc,
|
||||
/feedback add\s+File a self_feedback entry.*--purpose <fragment>/s,
|
||||
"help: feedback add must list --purpose <fragment>",
|
||||
);
|
||||
assert.match(
|
||||
helpSrc,
|
||||
/feedback list\s+List self_feedback entries.*--purpose <fragment>/s,
|
||||
"help: feedback list must list --purpose <fragment>",
|
||||
);
|
||||
});
|
||||
|
||||
test("insertSelfFeedbackEntry persists purposeAnchor and rowToSelfFeedback round-trips it", () => {
|
||||
openDatabase(":memory:");
|
||||
|
||||
const anchor = "M015 vision: machine-readable purpose contract";
|
||||
insertSelfFeedbackEntry(
|
||||
baseEntry({
|
||||
id: "purpose-roundtrip-1",
|
||||
summary: "anchored entry",
|
||||
purposeAnchor: anchor,
|
||||
}),
|
||||
);
|
||||
|
||||
const rows = listSelfFeedbackEntries();
|
||||
const reloaded = rows.find((r) => r.id === "purpose-roundtrip-1");
|
||||
assert.ok(reloaded, "inserted row must come back from the list");
|
||||
assert.equal(
|
||||
reloaded.purposeAnchor,
|
||||
anchor,
|
||||
"purposeAnchor must round-trip through SQLite to the JS surface",
|
||||
);
|
||||
});
|
||||
|
||||
test("insertSelfFeedbackEntry stores NULL when purposeAnchor is omitted (legacy compat)", () => {
|
||||
openDatabase(":memory:");
|
||||
|
||||
insertSelfFeedbackEntry(
|
||||
baseEntry({ id: "purpose-omitted-1", summary: "legacy-style entry" }),
|
||||
);
|
||||
|
||||
const rows = listSelfFeedbackEntries();
|
||||
const reloaded = rows.find((r) => r.id === "purpose-omitted-1");
|
||||
assert.ok(reloaded);
|
||||
// Legacy callers don't carry purposeAnchor — must come back as null/undefined,
|
||||
// not as the string "null" or an empty string.
|
||||
assert.ok(
|
||||
reloaded.purposeAnchor === null || reloaded.purposeAnchor === undefined,
|
||||
`expected null|undefined, got ${JSON.stringify(reloaded.purposeAnchor)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("listSelfFeedbackEntries({ purpose }) scopes by substring match on purpose_anchor", () => {
|
||||
openDatabase(":memory:");
|
||||
|
||||
insertSelfFeedbackEntry(
|
||||
baseEntry({
|
||||
id: "purpose-filter-m015",
|
||||
summary: "row tied to M015 vision",
|
||||
purposeAnchor: "M015 vision: triage prioritizes against purpose",
|
||||
}),
|
||||
);
|
||||
insertSelfFeedbackEntry(
|
||||
baseEntry({
|
||||
id: "purpose-filter-m019",
|
||||
summary: "row tied to a different milestone",
|
||||
purposeAnchor: "M019 goal: spec-first TDD reaches general availability",
|
||||
}),
|
||||
);
|
||||
insertSelfFeedbackEntry(
|
||||
baseEntry({
|
||||
id: "purpose-filter-none",
|
||||
summary: "free-floating legacy row",
|
||||
}),
|
||||
);
|
||||
|
||||
const allRows = listSelfFeedbackEntries();
|
||||
assert.equal(allRows.length, 3, "baseline: all three rows visible without filter");
|
||||
|
||||
const m015Rows = listSelfFeedbackEntries({ purpose: "M015" });
|
||||
assert.equal(m015Rows.length, 1, "filter must scope to the single M015-anchored row");
|
||||
assert.equal(m015Rows[0].id, "purpose-filter-m015");
|
||||
|
||||
// Substring matching: a fragment from the anchor's sentence body still hits.
|
||||
const triageRows = listSelfFeedbackEntries({
|
||||
purpose: "triage prioritizes",
|
||||
});
|
||||
assert.equal(
|
||||
triageRows.length,
|
||||
1,
|
||||
"sentence-fragment match must hit (operators paste fragments, not full anchors)",
|
||||
);
|
||||
assert.equal(triageRows[0].id, "purpose-filter-m015");
|
||||
|
||||
// Empty / whitespace purpose argument falls back to "no filter".
|
||||
const emptyRows = listSelfFeedbackEntries({ purpose: " " });
|
||||
assert.equal(
|
||||
emptyRows.length,
|
||||
3,
|
||||
"empty/whitespace purpose must not filter (treat as no filter)",
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue