Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
649fdff2ea | ||
|
|
9190fa0f10 | ||
|
|
6fb0cb38a5 | ||
|
|
cba542a3ec | ||
|
|
c28c87c39e | ||
|
|
8664d8ad14 | ||
|
|
ffbdf19116 | ||
|
|
dffbf43d84 | ||
|
|
31383bcebe | ||
|
|
0ab94b2a00 | ||
|
|
9c00a4b6e1 | ||
|
|
144192754c | ||
|
|
85111442a6 | ||
|
|
a2b377b74d | ||
|
|
590203a293 | ||
|
|
449f437048 | ||
|
|
48f0630dab | ||
|
|
a72605de8f | ||
|
|
4fa8158329 | ||
|
|
10687120a5 | ||
|
|
b88225eeb3 | ||
|
|
5925335ca0 | ||
|
|
2324c15418 | ||
|
|
bb242ca566 |
@@ -0,0 +1,35 @@
|
||||
"""code_shape_consumers — the CSS consumer map (milestone 302, note 2917)
|
||||
|
||||
Revision ID: 0086
|
||||
Revises: 0085
|
||||
Create Date: 2026-08-23
|
||||
|
||||
CSS is watched by name, by recipe, by token and by WHAT USES IT. This table
|
||||
holds the fourth: CSS shape → the file whose markup names its class, with how
|
||||
many times. Mechanical and recomputed by every coverage sync from the repo
|
||||
archive; the analogue of code_shape_uses for styling. Cascades with the shape.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0086"
|
||||
down_revision = "0085"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"code_shape_consumers",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("shape_id", sa.Integer(), sa.ForeignKey("code_shapes.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("path", sa.Text(), nullable=False),
|
||||
sa.Column("count", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("basis", sa.Text(), nullable=False, server_default="template"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.UniqueConstraint("shape_id", "path", name="uq_code_shape_consumers_shape_path"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("code_shape_consumers")
|
||||
@@ -172,6 +172,8 @@ endpoint at `/mcp`, not these REST routes.
|
||||
| GET | `/api/plugin/context` | SessionStart context payload (rules + active-project) |
|
||||
| GET | `/api/plugin/retrieve` | Title-first knowledge-injection candidates |
|
||||
| GET | `/api/plugin/processes` | Stored Processes for skill-stub sync |
|
||||
| GET | `/api/plugin/prior-art` | Write-path hint for the plugin hooks (params: `path`, `code`, `repo`, `shapes`, `exclude_ids`, `exclude_sync_ids`, `exclude_derive`); returns `context`, `note_ids`, `sync_note_ids`, `stamped`, `divergence`, `derive`, `derive_keys` |
|
||||
| GET / POST | `/api/projects/<id>/coverage`, `…/coverage/refresh` | Shape-ledger accounting (`pattern_coverage` line, counts, `derive_groups` — css groups carry `consumers`, `derive_new`, `unused_css`, `divergence`, `recheck`) |
|
||||
| GET / PUT | `/api/plugin/marketplace-url` | Read / set the plugin marketplace URL |
|
||||
|
||||
## Dashboard, Export, Trash, Users
|
||||
|
||||
@@ -297,3 +297,57 @@
|
||||
background: var(--fs-action-destructive-hover);
|
||||
border-color: var(--fs-action-destructive-hover);
|
||||
}
|
||||
|
||||
/* ── Page container ─────────────────────────────────────────────────────────
|
||||
The one wrapper a top-level view sits in: page width from the layout
|
||||
tokens, centred, clipped horizontally so a wide child (a kanban, a table)
|
||||
scrolls inside itself instead of the page. ProjectListView, ProjectView and
|
||||
SnippetListView each carried this rule under their own name until #2903
|
||||
(milestone 299). */
|
||||
.page-container {
|
||||
max-width: var(--fs-layout-page-max);
|
||||
margin: 2rem auto;
|
||||
padding: 0 var(--fs-layout-page-pad);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
/* ── Form input (fs-surfaces, snippet #2336) ────────────────────────────────
|
||||
Inputs sit DARKER than the page they're on — an inset well rather than a
|
||||
raised panel; that inversion is what makes a field read as writable. The
|
||||
design system's recipe, verbatim; width/box-sizing stay the caller's
|
||||
(an inline select and a full-width textarea differ there). Three scoped
|
||||
copies of an older input recipe were folded into this in #2903. */
|
||||
.fs-input {
|
||||
background: var(--fs-surface-page);
|
||||
border: var(--fs-border);
|
||||
border-radius: var(--fs-radius-md);
|
||||
padding: var(--fs-space-2) var(--fs-space-3); /* 8px 12px */
|
||||
color: var(--fs-text-primary);
|
||||
font-family: var(--fs-font-body);
|
||||
font-size: var(--fs-size-body);
|
||||
transition: box-shadow var(--fs-dur-fast) var(--fs-ease);
|
||||
}
|
||||
.fs-input::placeholder { color: var(--fs-text-tertiary); }
|
||||
.fs-input:focus { outline: none; box-shadow: var(--fs-focus-ring); }
|
||||
.fs-input:disabled { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
|
||||
|
||||
/* Page scaffold + feedback text recipes (milestone 302, note 2917): name
|
||||
families the consumer map showed to be one recipe living in many views.
|
||||
A view keeps only its deviation as a scoped remainder/override. */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.page-header h1 { margin: 0; }
|
||||
|
||||
.error-msg { color: var(--fs-error); font-size: 0.9rem; }
|
||||
.state-msg { color: var(--fs-text-tertiary); font-size: 0.9rem; }
|
||||
.empty-msg { color: var(--fs-text-tertiary); font-size: 0.875rem; }
|
||||
|
||||
.empty-title { font-size: 1rem; font-weight: 500; color: var(--fs-text-secondary); margin: 0 0 0.35rem; }
|
||||
.empty-sub { font-size: 0.85rem; color: var(--fs-text-tertiary); margin: 0 0 1rem; }
|
||||
|
||||
.required { color: var(--fs-error); }
|
||||
.field-hint { margin: 0.3rem 0 0; font-size: 0.8rem; color: var(--fs-text-tertiary); }
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/* The near-duplicate report, shared by KnowledgeView (notes/tasks) and
|
||||
SnippetListView (snippets) so the two reports read as one feature. Load
|
||||
with <style src="@/assets/dup-report.css" /> beside the view's scoped
|
||||
block; the view keeps only its own extras (.dup-claimed, .dup-action).
|
||||
Promoted from two identical scoped copies in #2903 (milestone 299). */
|
||||
.dup-panel {
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--fs-surface-hover);
|
||||
}
|
||||
.dup-empty,
|
||||
.dup-head {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.dup-empty { margin-bottom: 0; }
|
||||
.dup-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.dup-members {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
flex: 1 1 20rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.dup-member {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
|
||||
color: var(--fs-text-primary);
|
||||
text-decoration: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.dup-member:hover { background: var(--fs-surface-hover); }
|
||||
.dup-score {
|
||||
font-size: 0.75rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -78,7 +78,7 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.tag-pill {
|
||||
display: inline-flex;
|
||||
@@ -508,3 +508,36 @@
|
||||
opacity: var(--fs-disabled-opacity);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Shared by NoteEditorView and TaskEditorView — both carried identical scoped
|
||||
copies of these until #2903 (milestone 299); one source here. */
|
||||
.body-tabs-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.body-editor-wrap {
|
||||
min-height: 200px;
|
||||
}
|
||||
.stream-preview {
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.75rem;
|
||||
background: var(--fs-surface-raised);
|
||||
min-height: 200px;
|
||||
}
|
||||
.main-diff {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.assist-section-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--fs-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/* Shared by the three rules panes (RulebookListPane, RuleListPane,
|
||||
RulebookDetailPane): the pane surface and its heading. Load with
|
||||
<style src="@/assets/rules-shared.css" /> beside the component's own
|
||||
scoped block; never restate these there (#2903, milestone 299). */
|
||||
.pane {
|
||||
background: var(--fs-surface-hover);
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.pane header h2 {
|
||||
font-family: Fraunces, serif;
|
||||
font-style: italic;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
.form-buttons { display: flex; gap: 0.5rem; }
|
||||
@@ -185,5 +185,4 @@ onMounted(load);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.inception-actions { display: flex; justify-content: flex-end; margin-top: 0.5rem; }
|
||||
.error-msg { color: var(--fs-error); font-size: 0.9rem; }
|
||||
</style>
|
||||
|
||||
@@ -51,7 +51,7 @@ function onChange(e: Event) {
|
||||
|
||||
<template>
|
||||
<select
|
||||
class="milestone-select"
|
||||
class="fs-input milestone-select"
|
||||
:value="modelValue ?? ''"
|
||||
:disabled="!projectId || loading"
|
||||
@change="onChange"
|
||||
@@ -64,23 +64,10 @@ function onChange(e: Event) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* The input itself is the .fs-input canon (components.css); only the
|
||||
layout remainder lives here. */
|
||||
.milestone-select {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.milestone-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
.milestone-select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -176,7 +176,7 @@ async function confirmDelete() {
|
||||
<form v-if="showCreate" class="system-form" @submit.prevent="submitCreate">
|
||||
<input
|
||||
v-model="newName"
|
||||
class="system-input"
|
||||
class="fs-input system-input"
|
||||
placeholder="System name"
|
||||
aria-label="System name"
|
||||
autofocus
|
||||
@@ -184,7 +184,7 @@ async function confirmDelete() {
|
||||
/>
|
||||
<textarea
|
||||
v-model="newDescription"
|
||||
class="system-textarea"
|
||||
class="fs-input system-textarea"
|
||||
rows="2"
|
||||
placeholder="What is this subsystem responsible for? (optional)"
|
||||
aria-label="System description"
|
||||
@@ -227,7 +227,7 @@ async function confirmDelete() {
|
||||
<form class="system-form system-form--inline" @submit.prevent="submitEdit(system)">
|
||||
<input
|
||||
v-model="editName"
|
||||
class="system-input"
|
||||
class="fs-input system-input"
|
||||
placeholder="System name"
|
||||
aria-label="System name"
|
||||
autofocus
|
||||
@@ -235,7 +235,7 @@ async function confirmDelete() {
|
||||
/>
|
||||
<textarea
|
||||
v-model="editDescription"
|
||||
class="system-textarea"
|
||||
class="fs-input system-textarea"
|
||||
rows="2"
|
||||
placeholder="Description (optional)"
|
||||
aria-label="System description"
|
||||
@@ -372,18 +372,9 @@ async function confirmDelete() {
|
||||
border-radius: var(--fs-radius-lg);
|
||||
}
|
||||
.system-form--inline { padding: 0; background: none; border: none; flex: 1; }
|
||||
.system-input, .system-textarea {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.system-input:focus, .system-textarea:focus { outline: none; border-color: var(--fs-accent); }
|
||||
/* The input itself is the .fs-input canon (components.css); only the
|
||||
layout remainder lives here. */
|
||||
.system-input, .system-textarea { box-sizing: border-box; width: 100%; }
|
||||
.system-textarea { resize: vertical; }
|
||||
|
||||
.system-form-actions { display: flex; gap: 0.4rem; }
|
||||
@@ -493,10 +484,10 @@ async function confirmDelete() {
|
||||
border: 1px dashed var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
}
|
||||
.empty-title { margin: 0; font-weight: 500; color: var(--fs-text-primary); }
|
||||
.empty-sub { margin: 0 0 0.5rem; font-size: 0.82rem; color: var(--fs-text-tertiary); max-width: 32ch; }
|
||||
/* remainders over the shared recipes (components.css, m302) */
|
||||
.empty-title { margin: 0; color: var(--fs-text-primary); }
|
||||
.empty-sub { margin: 0 0 0.5rem; font-size: 0.82rem; max-width: 32ch; }
|
||||
|
||||
.error-msg { color: var(--fs-error); font-size: 0.9rem; }
|
||||
|
||||
/* ── Skeleton ─────────────────────────────────────────────────── */
|
||||
@keyframes skel-shine { to { background-position: 200% center; } }
|
||||
|
||||
@@ -21,9 +21,8 @@ const emit = defineEmits<{
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/rules-shared.css" />
|
||||
<style scoped>
|
||||
.pane { background: var(--fs-surface-hover); padding: 1rem; overflow-y: auto; }
|
||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||
li {
|
||||
padding: 0.75rem;
|
||||
|
||||
@@ -121,10 +121,9 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/rules-shared.css" />
|
||||
<style scoped>
|
||||
.pane { background: var(--fs-surface-hover); padding: 1rem; overflow-y: auto; }
|
||||
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||
.always-on-toggle {
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
font-size: 0.85rem; opacity: 0.85; cursor: pointer;
|
||||
@@ -146,7 +145,6 @@ li:hover { background: var(--fs-surface-hover); }
|
||||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.form-buttons { display: flex; gap: 0.5rem; }
|
||||
.subscriptions {
|
||||
margin-top: 2rem;
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
|
||||
@@ -47,9 +47,8 @@ async function submitNew() {
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/rules-shared.css" />
|
||||
<style scoped>
|
||||
.pane { background: var(--fs-surface-hover); padding: 1rem; overflow-y: auto; }
|
||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; }
|
||||
li.active { background: var(--fs-accent-soft); }
|
||||
@@ -71,6 +70,5 @@ li:hover { background: var(--fs-surface-hover); }
|
||||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.form-buttons { display: flex; gap: 0.5rem; }
|
||||
button { cursor: pointer; }
|
||||
</style>
|
||||
|
||||
@@ -557,14 +557,14 @@ function isSelfContainedColour(value: string): boolean {
|
||||
<div class="field">
|
||||
<label class="field-label" for="first-title">Title</label>
|
||||
<input
|
||||
id="first-title" v-model="newTitle" class="input" type="text"
|
||||
id="first-title" v-model="newTitle" class="fs-input input" type="text"
|
||||
placeholder="Your house style" @keyup.enter="submitCreate"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="first-desc">Description</label>
|
||||
<input
|
||||
id="first-desc" v-model="newDescription" class="input" type="text"
|
||||
id="first-desc" v-model="newDescription" class="fs-input input" type="text"
|
||||
placeholder="What it covers"
|
||||
/>
|
||||
</div>
|
||||
@@ -612,20 +612,20 @@ function isSelfContainedColour(value: string): boolean {
|
||||
<div class="field">
|
||||
<label class="field-label" for="new-title">Title</label>
|
||||
<input
|
||||
id="new-title" v-model="newTitle" class="input" type="text"
|
||||
id="new-title" v-model="newTitle" class="fs-input input" type="text"
|
||||
placeholder="A house style, or one app in it" @keyup.enter="submitCreate"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="new-desc">Description</label>
|
||||
<input
|
||||
id="new-desc" v-model="newDescription" class="input" type="text"
|
||||
id="new-desc" v-model="newDescription" class="fs-input input" type="text"
|
||||
placeholder="What it covers"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="new-parent">Inherits from</label>
|
||||
<select id="new-parent" v-model="newParentId" class="input">
|
||||
<select id="new-parent" v-model="newParentId" class="fs-input input">
|
||||
<option :value="null">Nothing — this is a family system</option>
|
||||
<option v-for="s in systems" :key="s.id" :value="s.id">{{ s.title }}</option>
|
||||
</select>
|
||||
@@ -659,16 +659,16 @@ function isSelfContainedColour(value: string): boolean {
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="edit-title">Title</label>
|
||||
<input id="edit-title" v-model="editTitle" class="input" type="text" />
|
||||
<input id="edit-title" v-model="editTitle" class="fs-input input" type="text" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="edit-desc">Description</label>
|
||||
<input id="edit-desc" v-model="editDescription" class="input" type="text" />
|
||||
<input id="edit-desc" v-model="editDescription" class="fs-input input" type="text" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="edit-guidance">Guidance</label>
|
||||
<textarea
|
||||
id="edit-guidance" v-model="editGuidance" class="input" rows="5"
|
||||
id="edit-guidance" v-model="editGuidance" class="fs-input input" rows="5"
|
||||
placeholder="Aesthetic, voice and tone, what's deliberately out of scope…"
|
||||
></textarea>
|
||||
<p class="field-hint">
|
||||
@@ -678,7 +678,7 @@ function isSelfContainedColour(value: string): boolean {
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="edit-parent">Inherits from</label>
|
||||
<select id="edit-parent" v-model="editParentId" class="input">
|
||||
<select id="edit-parent" v-model="editParentId" class="fs-input input">
|
||||
<option :value="null">Nothing — this is a family system</option>
|
||||
<option v-for="s in parentOptions" :key="s.id" :value="s.id">{{ s.title }}</option>
|
||||
</select>
|
||||
@@ -887,19 +887,19 @@ function isSelfContainedColour(value: string): boolean {
|
||||
<div class="field">
|
||||
<label class="field-label" for="token-name">Name</label>
|
||||
<input
|
||||
id="token-name" v-model="tokenName" class="input mono" type="text"
|
||||
id="token-name" v-model="tokenName" class="fs-input input mono" type="text"
|
||||
placeholder="--surface-page"
|
||||
/>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label class="field-label" for="token-group">Group</label>
|
||||
<input id="token-group" v-model="tokenGroup" class="input" type="text" placeholder="surface" />
|
||||
<input id="token-group" v-model="tokenGroup" class="fs-input input" type="text" placeholder="surface" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="token-purpose">Purpose</label>
|
||||
<input
|
||||
id="token-purpose" v-model="tokenPurpose" class="input" type="text"
|
||||
id="token-purpose" v-model="tokenPurpose" class="fs-input input" type="text"
|
||||
placeholder="page background, deepest surface"
|
||||
/>
|
||||
</div>
|
||||
@@ -908,7 +908,7 @@ function isSelfContainedColour(value: string): boolean {
|
||||
<div class="field">
|
||||
<label class="field-label" for="token-rationale">Why this value</label>
|
||||
<input
|
||||
id="token-rationale" v-model="tokenRationale" class="input" type="text"
|
||||
id="token-rationale" v-model="tokenRationale" class="fs-input input" type="text"
|
||||
placeholder="Matches the primary action colour, deliberately"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
@@ -920,7 +920,7 @@ function isSelfContainedColour(value: string): boolean {
|
||||
<div class="field">
|
||||
<label class="field-label" for="token-supersedes">Use instead of</label>
|
||||
<input
|
||||
id="token-supersedes" v-model="tokenSupersedes" class="input mono" type="text"
|
||||
id="token-supersedes" v-model="tokenSupersedes" class="fs-input input mono" type="text"
|
||||
placeholder="#fff, #ffffff"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
@@ -941,8 +941,8 @@ function isSelfContainedColour(value: string): boolean {
|
||||
</template>
|
||||
</p>
|
||||
<div v-for="(row, i) in tokenModes" :key="i" class="mode-row">
|
||||
<input v-model="row.mode" class="input mono mode-key" type="text" placeholder="base" />
|
||||
<input v-model="row.value" class="input mono" type="text" placeholder="#14171a" />
|
||||
<input v-model="row.mode" class="fs-input input mono mode-key" type="text" placeholder="base" />
|
||||
<input v-model="row.value" class="fs-input input mono" type="text" placeholder="#14171a" />
|
||||
<span
|
||||
v-if="isSelfContainedColour(row.value)" class="swatch"
|
||||
:style="{ background: row.value }" aria-hidden="true"
|
||||
@@ -1287,20 +1287,13 @@ function isSelfContainedColour(value: string): boolean {
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin: 0.3rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
line-height: 1.5;
|
||||
line-height: 1.5; /* remainder over the shared recipe */
|
||||
}
|
||||
|
||||
/* remainder over .fs-input (components.css, canon #2336; m302) */
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
color: var(--fs-text-primary);
|
||||
font: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -574,6 +574,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/dup-report.css" />
|
||||
<style scoped>
|
||||
/* ── Root layout ─────────────────────────────────────────── */
|
||||
.knowledge-root {
|
||||
@@ -1042,56 +1043,6 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
|
||||
/* ── Near-duplicate report ──────────────────────────────────────────────────
|
||||
Mirrors SnippetListView's panel so the two reports read as one feature.
|
||||
Scoped styles can't be shared across SFCs; if a third view ever grows this
|
||||
panel, promote the family to components.css and record it (#2464's rule:
|
||||
two-or-more is when a recipe earns the shared sheet). */
|
||||
.dup-panel {
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--fs-surface-hover);
|
||||
}
|
||||
.dup-empty,
|
||||
.dup-head {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.dup-empty { margin-bottom: 0; }
|
||||
.dup-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.dup-members {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
flex: 1 1 20rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.dup-member {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
|
||||
color: var(--fs-text-primary);
|
||||
text-decoration: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.dup-member:hover { background: var(--fs-surface-hover); }
|
||||
.dup-score {
|
||||
font-size: 0.75rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* A set someone already ruled on — quiet, not celebratory: it means "skip". */
|
||||
.dup-claimed {
|
||||
font-size: 0.72rem;
|
||||
|
||||
@@ -1,485 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import PaginationBar from "@/components/PaginationBar.vue";
|
||||
import { fmtLogStamp } from "@/utils/dateFormat";
|
||||
|
||||
const toastStore = useToastStore();
|
||||
|
||||
interface LogEntry {
|
||||
id: number;
|
||||
category: string;
|
||||
user_id: number | null;
|
||||
username: string | null;
|
||||
action: string | null;
|
||||
endpoint: string | null;
|
||||
method: string | null;
|
||||
status_code: number | null;
|
||||
duration_ms: number | null;
|
||||
ip_address: string | null;
|
||||
details: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface LogStats {
|
||||
audit: number;
|
||||
usage: number;
|
||||
error: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const logs = ref<LogEntry[]>([]);
|
||||
const stats = ref<LogStats>({ audit: 0, usage: 0, error: 0, total: 0 });
|
||||
const total = ref(0);
|
||||
const loading = ref(true);
|
||||
const expandedId = ref<number | null>(null);
|
||||
|
||||
// Filters
|
||||
const category = ref("");
|
||||
const search = ref("");
|
||||
const dateFrom = ref("");
|
||||
const dateTo = ref("");
|
||||
const limit = 50;
|
||||
const offset = ref(0);
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchLogs(), fetchStats()]);
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
watch([category, dateFrom, dateTo], () => {
|
||||
offset.value = 0;
|
||||
fetchLogs();
|
||||
});
|
||||
|
||||
watch(search, () => {
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
offset.value = 0;
|
||||
fetchLogs();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
watch(offset, () => {
|
||||
fetchLogs();
|
||||
});
|
||||
|
||||
async function fetchLogs() {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (category.value) params.set("category", category.value);
|
||||
if (search.value) params.set("search", search.value);
|
||||
if (dateFrom.value) params.set("date_from", dateFrom.value);
|
||||
if (dateTo.value) params.set("date_to", dateTo.value);
|
||||
params.set("limit", String(limit));
|
||||
params.set("offset", String(offset.value));
|
||||
|
||||
const data = await apiGet<{ logs: LogEntry[]; total: number }>(
|
||||
`/api/admin/logs?${params}`
|
||||
);
|
||||
logs.value = data.logs;
|
||||
total.value = data.total;
|
||||
} catch {
|
||||
toastStore.show("Failed to load logs", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
stats.value = await apiGet<LogStats>("/api/admin/logs/stats");
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpand(id: number) {
|
||||
expandedId.value = expandedId.value === id ? null : id;
|
||||
}
|
||||
|
||||
function formatDetails(details: string | null): string {
|
||||
if (!details) return "";
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(details), null, 2);
|
||||
} catch {
|
||||
return details;
|
||||
}
|
||||
}
|
||||
|
||||
function displayLabel(entry: LogEntry): string {
|
||||
if (entry.category === "audit" && entry.action) return entry.action;
|
||||
if (entry.endpoint) return entry.endpoint;
|
||||
return "—";
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
category.value = "";
|
||||
search.value = "";
|
||||
dateFrom.value = "";
|
||||
dateTo.value = "";
|
||||
offset.value = 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="logs-page">
|
||||
<h1>Application Logs</h1>
|
||||
|
||||
<section class="settings-section stats-section">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span class="stat-count">{{ stats.total.toLocaleString() }}</span>
|
||||
<span class="stat-label">Total</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-count stat-audit">{{ stats.audit.toLocaleString() }}</span>
|
||||
<span class="stat-label">Audit</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-count stat-usage">{{ stats.usage.toLocaleString() }}</span>
|
||||
<span class="stat-label">Usage</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-count stat-error">{{ stats.error.toLocaleString() }}</span>
|
||||
<span class="stat-label">Error</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Filters</h2>
|
||||
<div class="filter-bar">
|
||||
<select v-model="category" class="filter-select">
|
||||
<option value="">All categories</option>
|
||||
<option value="audit">Audit</option>
|
||||
<option value="usage">Usage</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
class="filter-input"
|
||||
/>
|
||||
<input v-model="dateFrom" type="date" class="filter-date" title="From date" />
|
||||
<input v-model="dateTo" type="date" class="filter-date" title="To date" />
|
||||
<button
|
||||
v-if="category || search || dateFrom || dateTo"
|
||||
class="btn-ghost btn-compact"
|
||||
@click="clearFilters"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<div v-if="loading" class="loading-msg">Loading logs...</div>
|
||||
|
||||
<div v-else-if="logs.length === 0" class="empty-msg">No log entries found.</div>
|
||||
|
||||
<template v-else>
|
||||
<table class="users-table logs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Category</th>
|
||||
<th class="hide-mobile">User</th>
|
||||
<th>Action / Endpoint</th>
|
||||
<th class="hide-mobile">IP</th>
|
||||
<th class="hide-mobile">Status</th>
|
||||
<th class="hide-mobile">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="entry in logs" :key="entry.id">
|
||||
<tr
|
||||
class="log-row"
|
||||
:class="{ 'row-expanded': expandedId === entry.id }"
|
||||
@click="toggleExpand(entry.id)"
|
||||
>
|
||||
<td class="cell-time">{{ fmtLogStamp(entry.created_at) }}</td>
|
||||
<td>
|
||||
<span class="category-badge" :class="'cat-' + entry.category">
|
||||
{{ entry.category }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="hide-mobile cell-user">{{ entry.username || "—" }}</td>
|
||||
<td class="cell-action">
|
||||
<span v-if="entry.method" class="method-tag">{{ entry.method }}</span>
|
||||
{{ displayLabel(entry) }}
|
||||
</td>
|
||||
<td class="hide-mobile cell-ip">{{ entry.ip_address || "—" }}</td>
|
||||
<td class="hide-mobile cell-status">
|
||||
<span v-if="entry.status_code" :class="entry.status_code >= 400 ? 'text-error' : ''">
|
||||
{{ entry.status_code }}
|
||||
</span>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
<td class="hide-mobile cell-duration">
|
||||
{{ entry.duration_ms != null ? entry.duration_ms + "ms" : "—" }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="expandedId === entry.id && (entry.details || entry.ip_address)" class="detail-row">
|
||||
<td colspan="7">
|
||||
<div v-if="entry.ip_address" class="detail-ip">IP: {{ entry.ip_address }}</div>
|
||||
<pre v-if="entry.details" class="detail-json">{{ formatDetails(entry.details) }}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<PaginationBar
|
||||
:total="total"
|
||||
:limit="limit"
|
||||
:offset="offset"
|
||||
@update:offset="offset = $event"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.logs-page {
|
||||
max-width: 1200px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
.logs-page h1 {
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
.settings-section {
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.settings-section h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.stats-section {
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
.stats-grid {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.stat-count {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.stat-audit {
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
.stat-usage {
|
||||
color: var(--fs-success);
|
||||
}
|
||||
.stat-error {
|
||||
color: var(--fs-error);
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filter-select,
|
||||
.filter-input,
|
||||
.filter-date {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.filter-select {
|
||||
min-width: 140px;
|
||||
}
|
||||
.filter-input {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
.filter-date {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.loading-msg,
|
||||
.empty-msg {
|
||||
text-align: center;
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.logs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.logs-table th {
|
||||
text-align: left;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--fs-text-tertiary);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.logs-table td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.logs-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.log-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.log-row:hover {
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
.row-expanded {
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
.cell-time {
|
||||
white-space: nowrap;
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.cell-user {
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
.cell-action {
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cell-status {
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.cell-ip {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cell-duration {
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.detail-ip {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.text-error {
|
||||
color: var(--fs-error);
|
||||
}
|
||||
|
||||
/* Category badges */
|
||||
.category-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
}
|
||||
.cat-audit {
|
||||
color: var(--fs-accent);
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
}
|
||||
.cat-usage {
|
||||
color: var(--fs-success);
|
||||
background: color-mix(in srgb, var(--fs-success) 15%, transparent);
|
||||
}
|
||||
.cat-error {
|
||||
color: var(--fs-error);
|
||||
background: color-mix(in srgb, var(--fs-error) 15%, transparent);
|
||||
}
|
||||
|
||||
/* Method tag */
|
||||
.method-tag {
|
||||
display: inline-block;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
font-family: monospace;
|
||||
padding: 0.05rem 0.25rem;
|
||||
border-radius: 3px;
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-tertiary);
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
/* Detail row */
|
||||
/* `.detail-row` is deliberately bare: a `<tr>` has nothing to style that its
|
||||
cells don't carry, and the row exists to scope the rule below (#2444). */
|
||||
.detail-row td {
|
||||
padding: 0 0.75rem 0.75rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.detail-json {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
font-size: 0.8rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.stat-card {
|
||||
min-width: calc(50% - 0.5rem);
|
||||
}
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
}
|
||||
.filter-select,
|
||||
.filter-input,
|
||||
.filter-date {
|
||||
width: 100%;
|
||||
}
|
||||
.cell-action {
|
||||
max-width: 160px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -626,16 +626,6 @@ onUnmounted(() => assist.clearSelection());
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.body-tabs-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
|
||||
.editor-tabs {
|
||||
display: inline-flex;
|
||||
background: var(--fs-surface-page);
|
||||
@@ -673,28 +663,11 @@ onUnmounted(() => assist.clearSelection());
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.body-editor-wrap {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.stream-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.stream-preview {
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.75rem;
|
||||
background: var(--fs-surface-raised);
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.main-diff {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Right sidebar */
|
||||
.note-sidebar {
|
||||
width: 280px;
|
||||
@@ -721,14 +694,6 @@ onUnmounted(() => assist.clearSelection());
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
|
||||
/* Tag suggest row inside sidebar */
|
||||
.tag-suggest-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Link Suggestions */
|
||||
.link-suggest-field { gap: 0.4rem; }
|
||||
|
||||
@@ -798,14 +763,6 @@ onUnmounted(() => assist.clearSelection());
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.assist-section-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--fs-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* ── Process editor ─────────────────────────────────────── */
|
||||
.ef-label {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
|
||||
@@ -171,7 +171,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="projects-list">
|
||||
<main class="page-container">
|
||||
<div class="page-header">
|
||||
<h1>Projects</h1>
|
||||
<button class="btn-primary" @click="openNewProjectModal">+ New Project</button>
|
||||
@@ -336,22 +336,6 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.projects-list {
|
||||
max-width: var(--fs-layout-page-max);
|
||||
margin: 2rem auto;
|
||||
padding: 0 var(--fs-layout-page-pad);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Moss action-primary per Hybrid — list-view utility action,
|
||||
not a brand moment. Empty-state .empty-action below keeps accent. */
|
||||
@@ -383,20 +367,17 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.loading-msg,
|
||||
.error-msg {
|
||||
.loading-msg {
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.error-msg {
|
||||
color: var(--fs-error);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.empty-state-rich { text-align: center; padding: 3rem 1rem; color: var(--fs-text-tertiary); }
|
||||
.empty-icon { font-size: 2.5rem; margin-bottom: 0.75rem; opacity: 0.3; }
|
||||
.empty-title { font-size: 1rem; font-weight: 500; color: var(--fs-text-secondary); margin: 0 0 0.35rem; }
|
||||
.empty-sub { font-size: 0.85rem; margin: 0 0 1rem; }
|
||||
.empty-action { display: inline-block; padding: 0.4rem 1rem; border: 1px solid var(--fs-action-primary); border-radius: var(--fs-radius-sm); color: var(--fs-action-primary); background: none; cursor: pointer; font-size: 0.85rem; transition: background 0.15s, color 0.15s; }
|
||||
.empty-action:hover { background: var(--fs-action-primary); color: var(--fs-text-on-action); }
|
||||
|
||||
@@ -599,9 +580,6 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
font-weight: 500;
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.required {
|
||||
color: var(--fs-error);
|
||||
}
|
||||
.modal-input,
|
||||
.modal-textarea {
|
||||
padding: 0.45rem 0.7rem;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { apiGet, apiPatch, apiDelete, apiPost, apiPut } from "@/api/client";
|
||||
import { apiGet, apiPatch, apiDelete, apiPost, apiPut, apiErrorMessage } from "@/api/client";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { useTasksStore } from "@/stores/tasks";
|
||||
@@ -543,8 +543,7 @@ async function saveForgePin() {
|
||||
if (project.value) project.value.forge_connection_id = forgePin.value;
|
||||
await loadCoverage();
|
||||
} catch (e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
toast.show(body?.error || "Failed to change the project's forge", "error");
|
||||
toast.show(apiErrorMessage(e, "Failed to change the project's forge"), "error");
|
||||
forgePin.value = project.value?.forge_connection_id ?? null;
|
||||
} finally {
|
||||
savingForgePin.value = false;
|
||||
@@ -641,7 +640,7 @@ async function confirmDelete() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="project-view">
|
||||
<main class="page-container">
|
||||
|
||||
<!-- Nav bar -->
|
||||
<div class="page-header">
|
||||
@@ -874,15 +873,15 @@ async function confirmDelete() {
|
||||
paragraph in practice — this one showed as "Maintain Scribe as
|
||||
the reliabl" and gave no way to read the rest without arrowing
|
||||
through it. -->
|
||||
<textarea v-model="editGoal" class="edit-textarea" rows="4" placeholder="What are you trying to achieve?"></textarea>
|
||||
<textarea v-model="editGoal" class="fs-input edit-textarea" rows="4" placeholder="What are you trying to achieve?"></textarea>
|
||||
</div>
|
||||
<div class="edit-field">
|
||||
<label class="edit-label">Description</label>
|
||||
<textarea v-model="editDescription" class="edit-textarea" rows="6" placeholder="Optional description..."></textarea>
|
||||
<textarea v-model="editDescription" class="fs-input edit-textarea" rows="6" placeholder="Optional description..."></textarea>
|
||||
</div>
|
||||
<div class="edit-field">
|
||||
<label class="edit-label">Status</label>
|
||||
<select v-model="editStatus" class="edit-select">
|
||||
<select v-model="editStatus" class="fs-input edit-select">
|
||||
<option value="active">Active</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="completed">Completed</option>
|
||||
@@ -891,7 +890,7 @@ async function confirmDelete() {
|
||||
</div>
|
||||
<div v-if="designSystems.length" class="edit-field">
|
||||
<label class="edit-label" for="project-design-system">Design system</label>
|
||||
<select id="project-design-system" v-model="editDesignSystemId" class="edit-select">
|
||||
<select id="project-design-system" v-model="editDesignSystemId" class="fs-input edit-select">
|
||||
<option :value="null">None</option>
|
||||
<option v-for="ds in designSystems" :key="ds.id" :value="ds.id">{{ ds.title }}</option>
|
||||
</select>
|
||||
@@ -1202,19 +1201,9 @@ async function confirmDelete() {
|
||||
|
||||
<style scoped>
|
||||
/* ── Layout ─────────────────────────────────────────────────── */
|
||||
.project-view {
|
||||
max-width: var(--fs-layout-page-max);
|
||||
margin: 2rem auto;
|
||||
padding: 0 var(--fs-layout-page-pad);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
/* ── Nav bar ─────────────────────────────────────────────────── */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: 1.5rem; /* roomier than the shared recipe */
|
||||
}
|
||||
.page-header-actions { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.plan-title-input {
|
||||
@@ -1409,7 +1398,7 @@ async function confirmDelete() {
|
||||
/* `minmax(0, 1fr)`, not `1fr`. A bare `1fr` track has an AUTO minimum, so it
|
||||
cannot shrink below its content — one wide descendant anywhere in the
|
||||
content column widens the whole column past the grid, and everything inside
|
||||
it then overflows the page and gets cut by `.project-view`'s
|
||||
it then overflows the page and gets cut by `.page-container`'s
|
||||
`overflow-x: clip`.
|
||||
This is the same property the header nav relies on and wants (neither side
|
||||
squeezed under its content); here it is exactly wrong, because the column
|
||||
@@ -1453,18 +1442,9 @@ async function confirmDelete() {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.edit-input, .edit-textarea, .edit-select {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.edit-input:focus, .edit-textarea:focus, .edit-select:focus { outline: none; border-color: var(--fs-accent); }
|
||||
/* The input itself is the .fs-input canon (components.css); only the
|
||||
layout remainder lives here. */
|
||||
.edit-input, .edit-textarea, .edit-select { box-sizing: border-box; width: 100%; }
|
||||
.edit-textarea { resize: vertical; }
|
||||
|
||||
/* Save panel: Moss action-primary per Hybrid rule */
|
||||
@@ -1833,7 +1813,7 @@ async function confirmDelete() {
|
||||
.note-title { font-weight: 500; min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.note-date { font-size: 0.75rem; color: var(--fs-text-tertiary); flex-shrink: 0; }
|
||||
|
||||
.empty-msg { color: var(--fs-text-tertiary); font-size: 0.875rem; text-align: center; padding: 1rem; }
|
||||
.empty-msg { text-align: center; padding: 1rem; } /* remainder over the shared recipe */
|
||||
/* Deliberately NOT styled like .empty-msg: "no tasks" and "the tasks did not
|
||||
load" look identical to a user, and conflating them is what let a silent
|
||||
failure read as an empty project. */
|
||||
|
||||
@@ -780,8 +780,7 @@ async function saveConnection() {
|
||||
connFormOpen.value = false;
|
||||
await loadForgeConnections();
|
||||
} catch (e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
toastStore.show(body?.error || "Failed to save forge connection", "error");
|
||||
toastStore.show(apiErrorMessage(e, "Failed to save forge connection"), "error");
|
||||
} finally {
|
||||
savingConn.value = false;
|
||||
}
|
||||
@@ -793,8 +792,7 @@ async function removeConnection(id: number) {
|
||||
connTestResult.value = null;
|
||||
await loadForgeConnections();
|
||||
} catch (e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
toastStore.show(body?.error || "Failed to delete forge connection", "error");
|
||||
toastStore.show(apiErrorMessage(e, "Failed to delete forge connection"), "error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -810,10 +808,9 @@ async function testConnection(id: number) {
|
||||
message: `Connected — ${res.version}, authenticated as ${res.username}`,
|
||||
};
|
||||
} catch (e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
connTestResult.value = {
|
||||
id, ok: false,
|
||||
message: body?.error || "Connection test failed",
|
||||
message: apiErrorMessage(e, "Connection test failed"),
|
||||
};
|
||||
} finally {
|
||||
testingConnId.value = 0;
|
||||
@@ -830,8 +827,7 @@ async function saveForgeWebhook() {
|
||||
forgeWebhookSaved.value = true;
|
||||
setTimeout(() => (forgeWebhookSaved.value = false), 2000);
|
||||
} catch (e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
toastStore.show(body?.error || "Failed to save webhook secret", "error");
|
||||
toastStore.show(apiErrorMessage(e, "Failed to save webhook secret"), "error");
|
||||
} finally {
|
||||
savingForgeWebhook.value = false;
|
||||
}
|
||||
@@ -864,8 +860,7 @@ async function saveMarketplaceUrl() {
|
||||
marketplaceUrlSaved.value = true;
|
||||
setTimeout(() => (marketplaceUrlSaved.value = false), 2000);
|
||||
} catch (e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
toastStore.show(body?.error || "Failed to save marketplace URL", "error");
|
||||
toastStore.show(apiErrorMessage(e, "Failed to save marketplace URL"), "error");
|
||||
} finally {
|
||||
savingMarketplaceUrl.value = false;
|
||||
}
|
||||
@@ -882,8 +877,7 @@ async function saveDbMaintenance() {
|
||||
dbMaintSaved.value = true;
|
||||
setTimeout(() => (dbMaintSaved.value = false), 2000);
|
||||
} catch (e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
toastStore.show(body?.error || "Failed to save maintenance settings", "error");
|
||||
toastStore.show(apiErrorMessage(e, "Failed to save maintenance settings"), "error");
|
||||
} finally {
|
||||
savingDbMaint.value = false;
|
||||
}
|
||||
@@ -912,8 +906,7 @@ async function runDbMaintenanceNow() {
|
||||
);
|
||||
await loadDbHealth(); // reflect the dead-tuple drop
|
||||
} catch (e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
toastStore.show(body?.error || "Maintenance run failed", "error");
|
||||
toastStore.show(apiErrorMessage(e, "Maintenance run failed"), "error");
|
||||
} finally {
|
||||
runningDbMaint.value = false;
|
||||
}
|
||||
@@ -1144,8 +1137,7 @@ async function sendInvite() {
|
||||
inviteEmail.value = "";
|
||||
await fetchInvitations();
|
||||
} catch (e: unknown) {
|
||||
const body = (e as { body?: { error?: string } })?.body;
|
||||
toastStore.show(body?.error || "Failed to send invitation", "error");
|
||||
toastStore.show(apiErrorMessage(e, "Failed to send invitation"), "error");
|
||||
} finally {
|
||||
sendingInvite.value = false;
|
||||
}
|
||||
@@ -1196,8 +1188,7 @@ async function deleteUser(userId: number) {
|
||||
users.value = users.value.filter((u) => u.id !== userId);
|
||||
toastStore.show("User deleted");
|
||||
} catch (e: unknown) {
|
||||
const body = (e as { body?: { error?: string } })?.body;
|
||||
toastStore.show(body?.error || "Failed to delete user", "error");
|
||||
toastStore.show(apiErrorMessage(e, "Failed to delete user"), "error");
|
||||
} finally {
|
||||
deleting.value = null;
|
||||
}
|
||||
@@ -1245,7 +1236,7 @@ async function deleteUser(userId: number) {
|
||||
id="user-timezone"
|
||||
v-model="userTimezone"
|
||||
type="text"
|
||||
class="input"
|
||||
class="fs-input input"
|
||||
placeholder="e.g. America/New_York"
|
||||
/>
|
||||
<button class="btn-secondary" type="button" @click="detectTimezone">Detect</button>
|
||||
@@ -1272,7 +1263,7 @@ async function deleteUser(userId: number) {
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input"
|
||||
class="fs-input input"
|
||||
style="max-width: 8rem"
|
||||
/>
|
||||
<p class="field-hint">Set to <strong>0</strong> to keep deleted items forever (never auto-purge).</p>
|
||||
@@ -1310,7 +1301,7 @@ async function deleteUser(userId: number) {
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
class="input"
|
||||
class="fs-input input"
|
||||
style="max-width: 8rem"
|
||||
/>
|
||||
<p class="field-hint">Minimum similarity to surface a note. Higher = stricter (fewer, more certain). Deliberately above the 0.45 used for searches you trigger yourself.</p>
|
||||
@@ -1324,7 +1315,7 @@ async function deleteUser(userId: number) {
|
||||
min="1"
|
||||
max="10"
|
||||
step="1"
|
||||
class="input"
|
||||
class="fs-input input"
|
||||
style="max-width: 8rem"
|
||||
/>
|
||||
<p class="field-hint">Ceiling on titles surfaced at once (1–10).</p>
|
||||
@@ -1354,7 +1345,7 @@ async function deleteUser(userId: number) {
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
class="input"
|
||||
class="fs-input input"
|
||||
style="max-width: 8rem"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
@@ -1383,7 +1374,7 @@ async function deleteUser(userId: number) {
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
class="input"
|
||||
class="fs-input input"
|
||||
style="max-width: 8rem"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
@@ -1403,7 +1394,7 @@ async function deleteUser(userId: number) {
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
class="input"
|
||||
class="fs-input input"
|
||||
style="max-width: 8rem"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
@@ -1424,7 +1415,7 @@ async function deleteUser(userId: number) {
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
class="input"
|
||||
class="fs-input input"
|
||||
style="max-width: 8rem"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
@@ -1466,7 +1457,7 @@ async function deleteUser(userId: number) {
|
||||
v-model="newEmail"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
class="input"
|
||||
class="fs-input input"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -1476,7 +1467,7 @@ async function deleteUser(userId: number) {
|
||||
v-model="emailPassword"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="input"
|
||||
class="fs-input input"
|
||||
/>
|
||||
<p class="field-hint">Required to confirm the change.</p>
|
||||
</div>
|
||||
@@ -1500,7 +1491,7 @@ async function deleteUser(userId: number) {
|
||||
v-model="currentPassword"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="input"
|
||||
class="fs-input input"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -1510,7 +1501,7 @@ async function deleteUser(userId: number) {
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="input"
|
||||
class="fs-input input"
|
||||
/>
|
||||
<p class="field-hint">Must be at least 8 characters</p>
|
||||
</div>
|
||||
@@ -1521,7 +1512,7 @@ async function deleteUser(userId: number) {
|
||||
v-model="confirmNewPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="input"
|
||||
class="fs-input input"
|
||||
:class="{ 'input-error': confirmNewPassword && newPassword !== confirmNewPassword }"
|
||||
/>
|
||||
<p v-if="confirmNewPassword && newPassword !== confirmNewPassword" class="error-hint">
|
||||
@@ -1564,20 +1555,20 @@ async function deleteUser(userId: number) {
|
||||
<div class="assistant-grid">
|
||||
<div class="field">
|
||||
<label>Display Name</label>
|
||||
<input v-model="profile.display_name" type="text" class="input" placeholder="e.g. Alex" />
|
||||
<input v-model="profile.display_name" type="text" class="fs-input input" placeholder="e.g. Alex" />
|
||||
<p class="field-hint">How the assistant addresses you.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Job Title</label>
|
||||
<input v-model="profile.job_title" type="text" class="input" placeholder="e.g. Product Manager" />
|
||||
<input v-model="profile.job_title" type="text" class="fs-input input" placeholder="e.g. Product Manager" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Industry</label>
|
||||
<input v-model="profile.industry" type="text" class="input" placeholder="e.g. Technology" />
|
||||
<input v-model="profile.industry" type="text" class="fs-input input" placeholder="e.g. Technology" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Expertise Level</label>
|
||||
<select v-model="profile.expertise_level" class="input">
|
||||
<select v-model="profile.expertise_level" class="fs-input input">
|
||||
<option value="novice">Novice — explain things simply</option>
|
||||
<option value="intermediate">Intermediate — balanced explanations</option>
|
||||
<option value="expert">Expert — assume deep knowledge</option>
|
||||
@@ -1596,7 +1587,7 @@ async function deleteUser(userId: number) {
|
||||
<div class="assistant-grid">
|
||||
<div class="field">
|
||||
<label>Response Style</label>
|
||||
<select v-model="profile.response_style" class="input">
|
||||
<select v-model="profile.response_style" class="fs-input input">
|
||||
<option value="concise">Concise — short and direct</option>
|
||||
<option value="balanced">Balanced — default</option>
|
||||
<option value="detailed">Detailed — thorough explanations</option>
|
||||
@@ -1604,7 +1595,7 @@ async function deleteUser(userId: number) {
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Tone</label>
|
||||
<select v-model="profile.tone" class="input">
|
||||
<select v-model="profile.tone" class="fs-input input">
|
||||
<option value="casual">Casual — friendly and relaxed</option>
|
||||
<option value="professional">Professional — formal and precise</option>
|
||||
<option value="technical">Technical — jargon-friendly</option>
|
||||
@@ -1646,11 +1637,11 @@ async function deleteUser(userId: number) {
|
||||
<div class="assistant-grid" style="margin-top:0.75rem">
|
||||
<div class="field">
|
||||
<label>Start Time</label>
|
||||
<input v-model="profile.work_schedule.start" type="time" class="input" />
|
||||
<input v-model="profile.work_schedule.start" type="time" class="fs-input input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>End Time</label>
|
||||
<input v-model="profile.work_schedule.end" type="time" class="input" />
|
||||
<input v-model="profile.work_schedule.end" type="time" class="fs-input input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
@@ -1706,7 +1697,7 @@ async function deleteUser(userId: number) {
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="input"
|
||||
class="fs-input input"
|
||||
placeholder="Enter a search query..."
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
@@ -1772,17 +1763,17 @@ async function deleteUser(userId: number) {
|
||||
<div v-if="connFormOpen" class="smtp-grid">
|
||||
<div class="field">
|
||||
<label for="conn-kind">Forge</label>
|
||||
<select id="conn-kind" v-model="connForm.kind" class="input">
|
||||
<select id="conn-kind" v-model="connForm.kind" class="fs-input input">
|
||||
<option v-for="k in forgeKinds" :key="k" :value="k">{{ k }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="conn-base-url">Base URL</label>
|
||||
<input id="conn-base-url" v-model="connForm.base_url" type="text" placeholder="https://git.example.com" class="input" />
|
||||
<input id="conn-base-url" v-model="connForm.base_url" type="text" placeholder="https://git.example.com" class="fs-input input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="conn-token">API Token (read scope)</label>
|
||||
<input id="conn-token" v-model="connForm.token" type="password" class="input" />
|
||||
<input id="conn-token" v-model="connForm.token" type="password" class="fs-input input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
@@ -2072,7 +2063,7 @@ async function deleteUser(userId: number) {
|
||||
v-model="baseUrl"
|
||||
type="url"
|
||||
placeholder="https://notes.example.com"
|
||||
class="input"
|
||||
class="fs-input input"
|
||||
/>
|
||||
</div>
|
||||
<div class="actions">
|
||||
@@ -2097,7 +2088,7 @@ async function deleteUser(userId: number) {
|
||||
v-model="adminMarketplaceUrl"
|
||||
type="url"
|
||||
placeholder="https://git.example.com/you/Scribe.git"
|
||||
class="input"
|
||||
class="fs-input input"
|
||||
/>
|
||||
</div>
|
||||
<div class="actions">
|
||||
@@ -2124,7 +2115,7 @@ async function deleteUser(userId: number) {
|
||||
</div>
|
||||
<div class="field url-field">
|
||||
<label for="db-maint-hour">Run hour (UTC)</label>
|
||||
<select id="db-maint-hour" v-model.number="dbMaintHour" class="input">
|
||||
<select id="db-maint-hour" v-model.number="dbMaintHour" class="fs-input input">
|
||||
<option v-for="h in 24" :key="h - 1" :value="h - 1">
|
||||
{{ String(h - 1).padStart(2, '0') }}:00
|
||||
</option>
|
||||
@@ -2194,27 +2185,27 @@ async function deleteUser(userId: number) {
|
||||
<div class="smtp-grid">
|
||||
<div class="field">
|
||||
<label for="smtp-host">SMTP Host</label>
|
||||
<input id="smtp-host" v-model="smtp.smtp_host" type="text" placeholder="smtp.example.com" class="input" />
|
||||
<input id="smtp-host" v-model="smtp.smtp_host" type="text" placeholder="smtp.example.com" class="fs-input input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="smtp-port">Port</label>
|
||||
<input id="smtp-port" v-model="smtp.smtp_port" type="text" placeholder="587" class="input" />
|
||||
<input id="smtp-port" v-model="smtp.smtp_port" type="text" placeholder="587" class="fs-input input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="smtp-username">Username</label>
|
||||
<input id="smtp-username" v-model="smtp.smtp_username" type="text" class="input" />
|
||||
<input id="smtp-username" v-model="smtp.smtp_username" type="text" class="fs-input input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="smtp-password">Password</label>
|
||||
<input id="smtp-password" v-model="smtp.smtp_password" type="password" class="input" />
|
||||
<input id="smtp-password" v-model="smtp.smtp_password" type="password" class="fs-input input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="smtp-from-address">From Address</label>
|
||||
<input id="smtp-from-address" v-model="smtp.smtp_from_address" type="email" placeholder="noreply@example.com" class="input" />
|
||||
<input id="smtp-from-address" v-model="smtp.smtp_from_address" type="email" placeholder="noreply@example.com" class="fs-input input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="smtp-from-name">From Name</label>
|
||||
<input id="smtp-from-name" v-model="smtp.smtp_from_name" type="text" placeholder="Fabled Scribe" class="input" />
|
||||
<input id="smtp-from-name" v-model="smtp.smtp_from_name" type="text" placeholder="Fabled Scribe" class="fs-input input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="checkbox-field">
|
||||
@@ -2237,7 +2228,7 @@ async function deleteUser(userId: number) {
|
||||
v-model="testRecipient"
|
||||
type="email"
|
||||
placeholder="test@example.com"
|
||||
class="input"
|
||||
class="fs-input input"
|
||||
/>
|
||||
<button class="btn-primary" @click="sendTestEmail" :disabled="sendingTest || !testRecipient.trim()">
|
||||
{{ sendingTest ? "Sending..." : "Send Test" }}
|
||||
@@ -2258,7 +2249,7 @@ async function deleteUser(userId: number) {
|
||||
<div class="smtp-grid">
|
||||
<div class="field">
|
||||
<label for="forge-webhook-secret">Webhook Secret</label>
|
||||
<input id="forge-webhook-secret" v-model="forgeWebhookSecret" type="password" class="input" />
|
||||
<input id="forge-webhook-secret" v-model="forgeWebhookSecret" type="password" class="fs-input input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
@@ -2304,7 +2295,7 @@ async function deleteUser(userId: number) {
|
||||
v-model="inviteEmail"
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
class="input invite-input"
|
||||
class="fs-input input invite-input"
|
||||
required
|
||||
:disabled="sendingInvite"
|
||||
/>
|
||||
@@ -2740,25 +2731,10 @@ async function deleteUser(userId: number) {
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
/* remainder over .fs-input (components.css, canon #2336; m302) */
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
font-size: 0.9rem;
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
box-sizing: border-box;
|
||||
font-family: inherit;
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
.field-hint {
|
||||
margin: 0.3rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.field-hint-warn {
|
||||
display: block;
|
||||
@@ -3172,7 +3148,7 @@ async function deleteUser(userId: number) {
|
||||
.cell-status { font-family: monospace; font-size: 0.85rem; }
|
||||
.cell-duration { color: var(--fs-text-tertiary); font-size: 0.8rem; white-space: nowrap; }
|
||||
.text-error { color: var(--fs-error); }
|
||||
/* Bare by design, like LogsView's twin of this: a `<tr>` has nothing to style
|
||||
/* Bare by design: a `<tr>` has nothing to style
|
||||
that its cells don't carry (#2444). */
|
||||
.detail-row td { padding: 0 0.75rem 0.75rem; border-bottom: 1px solid var(--fs-border-color); }
|
||||
.detail-ip { font-family: monospace; font-size: 0.8rem; color: var(--fs-text-tertiary); margin-bottom: 0.4rem; }
|
||||
|
||||
@@ -98,7 +98,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
margin-bottom: 2rem; /* roomier than the shared recipe */
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@@ -247,8 +247,6 @@ onMounted(async () => {
|
||||
.perm-admin { background: color-mix(in srgb, var(--fs-warning) 15%, transparent); color: var(--fs-warning); }
|
||||
|
||||
.empty-msg {
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.88rem;
|
||||
margin: 0;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
@@ -220,14 +220,8 @@ async function confirmDelete() {
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
|
||||
.state-msg {
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.state-msg,
|
||||
.error-msg {
|
||||
color: var(--fs-error);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -227,7 +227,7 @@ function cancel() {
|
||||
ref="nameRef"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="input mono"
|
||||
class="fs-input input mono"
|
||||
placeholder="useDebouncedRef"
|
||||
@keydown.escape="cancel"
|
||||
/>
|
||||
@@ -239,7 +239,7 @@ function cancel() {
|
||||
id="sn-when"
|
||||
v-model="form.when_to_use"
|
||||
type="text"
|
||||
class="input"
|
||||
class="fs-input input"
|
||||
placeholder="Debounce a reactive ref that updates too often"
|
||||
@keydown.escape="cancel"
|
||||
/>
|
||||
@@ -253,7 +253,7 @@ function cancel() {
|
||||
id="sn-lang"
|
||||
v-model="form.language"
|
||||
type="text"
|
||||
class="input"
|
||||
class="fs-input input"
|
||||
placeholder="typescript"
|
||||
@keydown.escape="cancel"
|
||||
/>
|
||||
@@ -264,7 +264,7 @@ function cancel() {
|
||||
id="sn-sig"
|
||||
v-model="form.signature"
|
||||
type="text"
|
||||
class="input mono"
|
||||
class="fs-input input mono"
|
||||
placeholder="useDebouncedRef(value, ms)"
|
||||
@keydown.escape="cancel"
|
||||
/>
|
||||
@@ -277,9 +277,9 @@ function cancel() {
|
||||
<span class="hint-inline">— where the reference implementation(s) live; a merged snippet keeps every call site</span>
|
||||
</legend>
|
||||
<div v-for="(loc, i) in locations" :key="i" class="loc-row">
|
||||
<input v-model="loc.repo" type="text" class="input mono" placeholder="repo" aria-label="Repo" @keydown.escape="cancel" />
|
||||
<input v-model="loc.path" type="text" class="input mono" placeholder="path" aria-label="Path" @keydown.escape="cancel" />
|
||||
<input v-model="loc.symbol" type="text" class="input mono" placeholder="symbol" aria-label="Symbol" @keydown.escape="cancel" />
|
||||
<input v-model="loc.repo" type="text" class="fs-input input mono" placeholder="repo" aria-label="Repo" @keydown.escape="cancel" />
|
||||
<input v-model="loc.path" type="text" class="fs-input input mono" placeholder="path" aria-label="Path" @keydown.escape="cancel" />
|
||||
<input v-model="loc.symbol" type="text" class="fs-input input mono" placeholder="symbol" aria-label="Symbol" @keydown.escape="cancel" />
|
||||
<button
|
||||
type="button"
|
||||
class="loc-remove"
|
||||
@@ -296,7 +296,7 @@ function cancel() {
|
||||
<textarea
|
||||
id="sn-code"
|
||||
v-model="form.code"
|
||||
class="input mono code-area"
|
||||
class="fs-input input mono code-area"
|
||||
rows="14"
|
||||
spellcheck="false"
|
||||
placeholder="Paste the reusable implementation…"
|
||||
@@ -309,7 +309,7 @@ function cancel() {
|
||||
id="sn-tags"
|
||||
v-model="tagsText"
|
||||
type="text"
|
||||
class="input"
|
||||
class="fs-input input"
|
||||
placeholder="composable, ui (comma-separated)"
|
||||
@keydown.escape="cancel"
|
||||
/>
|
||||
@@ -383,14 +383,6 @@ function cancel() {
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
|
||||
.state-msg {
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.error-msg {
|
||||
color: var(--fs-error);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
@@ -417,9 +409,6 @@ function cancel() {
|
||||
font-weight: 500;
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.required {
|
||||
color: var(--fs-error);
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
@@ -430,21 +419,10 @@ function cancel() {
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
/* remainder over .fs-input (components.css, canon #2336; m302) */
|
||||
.input {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--fs-font-mono);
|
||||
|
||||
@@ -273,7 +273,7 @@ function usageTitle(s: SnippetListItem): string {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="snippets-list">
|
||||
<main class="page-container">
|
||||
<div class="page-header">
|
||||
<h1>Snippets</h1>
|
||||
<div class="header-actions">
|
||||
@@ -517,22 +517,10 @@ function usageTitle(s: SnippetListItem): string {
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/dup-report.css" />
|
||||
<style scoped>
|
||||
.snippets-list {
|
||||
max-width: var(--fs-layout-page-max);
|
||||
margin: 2rem auto;
|
||||
padding: 0 var(--fs-layout-page-pad);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
margin-bottom: 0.35rem; /* tighter than the shared recipe: .page-sub follows */
|
||||
}
|
||||
.page-sub {
|
||||
margin: 0 0 1.25rem;
|
||||
@@ -622,8 +610,6 @@ function usageTitle(s: SnippetListItem): string {
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: var(--fs-error);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@@ -638,15 +624,7 @@ function usageTitle(s: SnippetListItem): string {
|
||||
margin-bottom: 0.75rem;
|
||||
opacity: 0.35;
|
||||
}
|
||||
.empty-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--fs-text-secondary);
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
.empty-sub {
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 1rem;
|
||||
max-width: 44ch;
|
||||
margin-inline: auto;
|
||||
line-height: 1.5;
|
||||
@@ -764,59 +742,6 @@ function usageTitle(s: SnippetListItem): string {
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
/* Near-duplicate report */
|
||||
.dup-panel {
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--fs-surface-hover);
|
||||
}
|
||||
|
||||
.dup-empty,
|
||||
.dup-head {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.dup-empty {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.dup-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
}
|
||||
|
||||
.dup-members {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
flex: 1 1 20rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dup-member {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
|
||||
/* Long snippet names must not push the row into a horizontal scroll. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dup-score {
|
||||
font-size: 0.75rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dup-action {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -823,16 +823,6 @@ useEditorGuards(dirty, save);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.body-tabs-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
|
||||
/* .task-main is a flex column; without flex-shrink: 0, long body content
|
||||
gets squeezed back to min-height and overflows visibly on top of siblings. */
|
||||
.body-editor-wrap,
|
||||
@@ -840,10 +830,6 @@ useEditorGuards(dirty, save);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.body-editor-wrap {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
:deep(.preview-pane) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -949,18 +935,6 @@ useEditorGuards(dirty, save);
|
||||
font-family: inherit;
|
||||
}
|
||||
.subtask-input:focus { outline: none; border-color: var(--fs-accent); }
|
||||
.stream-preview {
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.75rem;
|
||||
background: var(--fs-surface-raised);
|
||||
min-height: 200px;
|
||||
}
|
||||
.main-diff {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Systems multi-select (in sidebar) */
|
||||
.sb-systems { display: flex; flex-direction: column; gap: 0.25rem; max-height: 160px; overflow-y: auto; }
|
||||
.sb-system-opt { display: flex; align-items: center; gap: 0.45rem; font-size: 0.85rem; color: var(--fs-text-primary); cursor: pointer; }
|
||||
@@ -973,26 +947,11 @@ useEditorGuards(dirty, save);
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.assist-section-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--fs-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.assist-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* Tag suggest row inside sidebar */
|
||||
.tag-suggest-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Lifecycle timestamps */
|
||||
.sb-timestamps {
|
||||
display: flex;
|
||||
|
||||
@@ -1,767 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, computed, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useTasksStore } from "@/stores/tasks";
|
||||
import { useNotesStore } from "@/stores/notes";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { relativeTime } from "@/composables/useRelativeTime";
|
||||
import { apiPost, apiGet } from "@/api/client";
|
||||
import type { Note } from "@/types/note";
|
||||
import type { TaskStatus } from "@/types/task";
|
||||
import StatusBadge from "@/components/StatusBadge.vue";
|
||||
import PriorityBadge from "@/components/PriorityBadge.vue";
|
||||
import TagPill from "@/components/TagPill.vue";
|
||||
import TableOfContents from "@/components/TableOfContents.vue";
|
||||
import ShareDialog from "@/components/ShareDialog.vue";
|
||||
import { Clock, Pencil, Link as LinkIcon } from "lucide-vue-next";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useTasksStore();
|
||||
const notesStore = useNotesStore();
|
||||
const backlinks = ref<{ type: string; id: number; title: string }[]>([]);
|
||||
const converting = ref(false);
|
||||
const showShare = ref(false);
|
||||
|
||||
// Context enrichment
|
||||
const projectTitle = ref<string | null>(null);
|
||||
const milestoneName = ref<string | null>(null);
|
||||
const subTasks = ref<Note[]>([]);
|
||||
|
||||
const taskId = computed(() => Number(route.params.id));
|
||||
|
||||
const statusCycle: Record<TaskStatus, TaskStatus> = {
|
||||
todo: "in_progress",
|
||||
in_progress: "done",
|
||||
done: "todo",
|
||||
cancelled: "todo",
|
||||
};
|
||||
|
||||
const statusDotClass: Record<TaskStatus, string> = {
|
||||
todo: "dot-todo",
|
||||
in_progress: "dot-in-progress",
|
||||
done: "dot-done",
|
||||
cancelled: "dot-cancelled",
|
||||
};
|
||||
|
||||
function cycleSubTaskStatus(subTask: Note) {
|
||||
if (!subTask.status) return;
|
||||
const next = statusCycle[subTask.status as TaskStatus];
|
||||
store.patchStatus(subTask.id, next).then(() => {
|
||||
const idx = subTasks.value.findIndex((t) => t.id === subTask.id);
|
||||
if (idx !== -1) subTasks.value[idx] = { ...subTasks.value[idx], status: next };
|
||||
});
|
||||
}
|
||||
|
||||
async function loadContext(task: Note) {
|
||||
projectTitle.value = null;
|
||||
milestoneName.value = null;
|
||||
subTasks.value = [];
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
if (task.project_id) {
|
||||
promises.push(
|
||||
apiGet<any>(`/api/projects/${task.project_id}`).then((data) => {
|
||||
projectTitle.value = data.title ?? null;
|
||||
if (task.milestone_id && data.summary?.milestone_summary) {
|
||||
const ms = (data.summary.milestone_summary as Array<{ id: number; title: string }>)
|
||||
.find((m) => m.id === task.milestone_id);
|
||||
if (ms) milestoneName.value = ms.title;
|
||||
}
|
||||
}).catch(() => {})
|
||||
);
|
||||
}
|
||||
|
||||
// Load sub-tasks via the notes endpoint with parent_id filter
|
||||
promises.push(
|
||||
apiGet<{ notes: Note[]; total: number }>(
|
||||
`/api/notes?parent_id=${task.id}&type=task&sort=created_at&order=asc&limit=50`
|
||||
).then((data) => {
|
||||
subTasks.value = data.notes;
|
||||
}).catch(() => {})
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
async function loadTask(id: number) {
|
||||
backlinks.value = [];
|
||||
await store.fetchTask(id);
|
||||
if (!store.currentTask) return;
|
||||
|
||||
const [bl] = await Promise.allSettled([
|
||||
notesStore.fetchBacklinks(id),
|
||||
loadContext(store.currentTask),
|
||||
]);
|
||||
if (bl.status === "fulfilled") backlinks.value = bl.value;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== "Escape") return;
|
||||
e.stopPropagation(); // prevent App.vue's global handler from also firing
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (active && active !== document.body) {
|
||||
(active as HTMLElement).blur();
|
||||
return;
|
||||
}
|
||||
if (store.currentTask?.project_id) {
|
||||
router.push(`/projects/${store.currentTask.project_id}`);
|
||||
} else {
|
||||
router.push("/tasks");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTask(taskId.value);
|
||||
// Capture phase so this fires before App.vue's document-level handler
|
||||
window.addEventListener("keydown", handleKeydown, true);
|
||||
});
|
||||
onUnmounted(() => window.removeEventListener("keydown", handleKeydown, true));
|
||||
|
||||
watch(() => route.params.id, (newId) => {
|
||||
if (newId) loadTask(Number(newId));
|
||||
});
|
||||
|
||||
const renderedBody = computed(() => {
|
||||
if (!store.currentTask) return "";
|
||||
return renderMarkdown(store.currentTask.body);
|
||||
});
|
||||
|
||||
function cycleStatus() {
|
||||
if (!store.currentTask) return;
|
||||
store.patchStatus(
|
||||
store.currentTask.id,
|
||||
statusCycle[store.currentTask.status as TaskStatus]
|
||||
);
|
||||
}
|
||||
|
||||
const forwardStatus: Record<TaskStatus, TaskStatus | null> = {
|
||||
todo: "in_progress",
|
||||
in_progress: "done",
|
||||
done: null,
|
||||
cancelled: null,
|
||||
};
|
||||
|
||||
function recurrenceSummary(rule: Record<string, unknown> | null): string | null {
|
||||
if (!rule) return null;
|
||||
if (rule.type === "interval") {
|
||||
return `Every ${rule.every} ${rule.unit}(s)`;
|
||||
}
|
||||
if (rule.type === "calendar") {
|
||||
if (rule.unit === "month") return `Monthly on day ${rule.day_of_month}`;
|
||||
if (rule.unit === "year") {
|
||||
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
||||
const m = months[((rule.month as number) ?? 1) - 1];
|
||||
return `Yearly on ${m} ${rule.day_of_month}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const advanceLabel = computed(() => {
|
||||
const s = store.currentTask?.status as TaskStatus | undefined;
|
||||
if (!s) return null;
|
||||
const next = forwardStatus[s];
|
||||
if (!next) return null;
|
||||
return next === "in_progress" ? "→ In Progress" : "→ Done";
|
||||
});
|
||||
|
||||
function advanceStatus() {
|
||||
if (!store.currentTask) return;
|
||||
const next = forwardStatus[store.currentTask.status as TaskStatus];
|
||||
if (next) store.patchStatus(store.currentTask.id, next);
|
||||
}
|
||||
|
||||
function isOverdue(): boolean {
|
||||
if (!store.currentTask?.due_date || store.currentTask.status === "done")
|
||||
return false;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return store.currentTask.due_date < today;
|
||||
}
|
||||
|
||||
async function convertToNote() {
|
||||
if (converting.value) return;
|
||||
converting.value = true;
|
||||
try {
|
||||
await notesStore.convertToNote(taskId.value);
|
||||
const { useToastStore } = await import("@/stores/toast");
|
||||
useToastStore().show("Converted to note");
|
||||
router.push(`/notes/${taskId.value}`);
|
||||
} catch {
|
||||
const { useToastStore } = await import("@/stores/toast");
|
||||
useToastStore().show("Failed to convert task", "error");
|
||||
} finally {
|
||||
converting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onBodyClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
const tagLink = target.closest(".inline-tag") as HTMLAnchorElement | null;
|
||||
if (tagLink) {
|
||||
e.preventDefault();
|
||||
const tag = tagLink.dataset.tag;
|
||||
if (tag) {
|
||||
router.push({ path: "/notes", query: { tag } });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const wikilink = target.closest(".wikilink") as HTMLAnchorElement | null;
|
||||
if (wikilink) {
|
||||
e.preventDefault();
|
||||
const title = wikilink.dataset.title;
|
||||
if (title) {
|
||||
try {
|
||||
const note = await apiPost<Note>(
|
||||
"/api/notes/resolve-title",
|
||||
{ title }
|
||||
);
|
||||
router.push(`/notes/${note.id}`);
|
||||
} catch {
|
||||
const { useToastStore } = await import("@/stores/toast");
|
||||
useToastStore().show(`Failed to resolve note "${title}"`, "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onTagClick(tag: string) {
|
||||
router.push({ path: "/tasks", query: { tag } });
|
||||
}
|
||||
|
||||
// Sub-task progress
|
||||
const subTaskProgress = computed(() => {
|
||||
if (!subTasks.value.length) return null;
|
||||
const done = subTasks.value.filter((t) => t.status === "done").length;
|
||||
const total = subTasks.value.length;
|
||||
return { done, total, pct: Math.round((done / total) * 100) };
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="viewer-layout">
|
||||
<main class="viewer">
|
||||
<div v-if="store.loading" class="viewer-skeleton" aria-label="Loading task">
|
||||
<div class="skel-toolbar">
|
||||
<div class="skel-btn"></div>
|
||||
<div class="skel-btn skel-btn--wide"></div>
|
||||
<div class="skel-btn"></div>
|
||||
</div>
|
||||
<div class="skel-title"></div>
|
||||
<div class="skel-meta"></div>
|
||||
<div class="skel-badges"></div>
|
||||
<div class="skel-line"></div>
|
||||
<div class="skel-line skel-line--short"></div>
|
||||
<div class="skel-line"></div>
|
||||
<div class="skel-line skel-line--medium"></div>
|
||||
<div class="skel-line skel-line--short"></div>
|
||||
</div>
|
||||
<template v-else-if="store.currentTask">
|
||||
<div class="toolbar">
|
||||
<router-link
|
||||
:to="store.currentTask.project_id ? `/projects/${store.currentTask.project_id}` : '/tasks'"
|
||||
class="btn-ghost"
|
||||
>{{ store.currentTask.project_id ? "← Project" : "← Tasks" }}</router-link>
|
||||
<router-link
|
||||
:to="`/tasks/${store.currentTask.id}/edit`"
|
||||
class="btn-primary"
|
||||
>
|
||||
Edit
|
||||
</router-link>
|
||||
<button
|
||||
v-if="advanceLabel"
|
||||
class="btn-primary"
|
||||
@click="advanceStatus"
|
||||
>
|
||||
{{ advanceLabel }}
|
||||
</button>
|
||||
<button
|
||||
class="btn-secondary btn-compact"
|
||||
@click="convertToNote"
|
||||
:disabled="converting"
|
||||
>
|
||||
{{ converting ? "Converting..." : "Convert to Note" }}
|
||||
</button>
|
||||
<button class="btn-secondary btn-compact" @click="showShare = true">Share</button>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb: parent task → project → milestone -->
|
||||
<div
|
||||
v-if="store.currentTask.parent_id || store.currentTask.project_id"
|
||||
class="context-bar"
|
||||
>
|
||||
<router-link
|
||||
v-if="store.currentTask.parent_id"
|
||||
:to="`/tasks/${store.currentTask.parent_id}`"
|
||||
class="ctx-crumb ctx-crumb-parent"
|
||||
>
|
||||
↑ {{ store.currentTask.parent_title || "Parent task" }}
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="store.currentTask.project_id && projectTitle"
|
||||
:to="`/projects/${store.currentTask.project_id}`"
|
||||
class="ctx-crumb ctx-crumb-project"
|
||||
>
|
||||
{{ projectTitle }}
|
||||
</router-link>
|
||||
<span v-if="milestoneName" class="ctx-crumb ctx-crumb-milestone">
|
||||
{{ milestoneName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 class="task-title">{{ store.currentTask.title || "Untitled" }}</h1>
|
||||
<p class="meta">
|
||||
<span class="meta-item">
|
||||
<Clock :size="16" />
|
||||
Updated {{ relativeTime(store.currentTask.updated_at) }}
|
||||
</span>
|
||||
<span class="meta-sep" aria-hidden="true">·</span>
|
||||
<span class="meta-item">
|
||||
<Pencil :size="16" />
|
||||
Created {{ relativeTime(store.currentTask.created_at) }}
|
||||
</span>
|
||||
</p>
|
||||
<div class="badges">
|
||||
<StatusBadge
|
||||
:status="store.currentTask.status!"
|
||||
clickable
|
||||
@click="cycleStatus"
|
||||
/>
|
||||
<PriorityBadge :priority="store.currentTask.priority!" />
|
||||
<span
|
||||
v-if="store.currentTask.due_date"
|
||||
:class="['due-date', { overdue: isOverdue() }]"
|
||||
>
|
||||
Due: {{ store.currentTask.due_date }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="task-meta-row" v-if="store.currentTask.started_at || store.currentTask.completed_at || store.currentTask.recurrence_rule">
|
||||
<span v-if="store.currentTask.started_at" class="task-meta-item">
|
||||
Started: {{ new Date(store.currentTask.started_at).toLocaleString() }}
|
||||
</span>
|
||||
<span v-if="store.currentTask.completed_at" class="task-meta-item">
|
||||
Completed: {{ new Date(store.currentTask.completed_at).toLocaleString() }}
|
||||
</span>
|
||||
<span v-if="recurrenceSummary(store.currentTask.recurrence_rule as Record<string, unknown> | null)" class="task-meta-item task-meta-recurrence">
|
||||
↻ {{ recurrenceSummary(store.currentTask.recurrence_rule as Record<string, unknown> | null) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tags" v-if="store.currentTask.tags.length">
|
||||
<TagPill
|
||||
v-for="tag in store.currentTask.tags"
|
||||
:key="tag"
|
||||
:tag="tag"
|
||||
@click="onTagClick"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="store.currentTask.description"
|
||||
class="task-goal-display"
|
||||
>
|
||||
<h3 class="goal-label">Goal</h3>
|
||||
<p class="goal-text">{{ store.currentTask.description }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div
|
||||
class="body prose"
|
||||
v-html="renderedBody"
|
||||
@click="onBodyClick"
|
||||
></div>
|
||||
|
||||
<!-- Sub-tasks -->
|
||||
<div v-if="subTasks.length" class="subtasks">
|
||||
<div class="subtasks-header">
|
||||
<h2 class="subtasks-title">Sub-tasks</h2>
|
||||
<span v-if="subTaskProgress" class="subtasks-progress">
|
||||
{{ subTaskProgress.done }}/{{ subTaskProgress.total }}
|
||||
<span class="subtasks-pct">({{ subTaskProgress.pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="subTaskProgress" class="subtasks-track">
|
||||
<div class="subtasks-fill" :style="{ width: subTaskProgress.pct + '%' }"></div>
|
||||
</div>
|
||||
<ul class="subtasks-list">
|
||||
<li
|
||||
v-for="sub in subTasks"
|
||||
:key="sub.id"
|
||||
class="subtask-row"
|
||||
>
|
||||
<button
|
||||
:class="['sub-dot', statusDotClass[sub.status as TaskStatus] ?? 'dot-todo']"
|
||||
:title="`${sub.status} — click to advance`"
|
||||
@click="cycleSubTaskStatus(sub)"
|
||||
></button>
|
||||
<router-link :to="`/tasks/${sub.id}/edit`" class="sub-title" :class="{ 'sub-done': sub.status === 'done' }">
|
||||
{{ sub.title || "Untitled" }}
|
||||
</router-link>
|
||||
<span v-if="sub.due_date" class="sub-due">{{ sub.due_date }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="backlinks.length" class="backlinks">
|
||||
<h3 class="backlinks-heading">
|
||||
<LinkIcon :size="16" />
|
||||
Backlinks
|
||||
<span class="backlinks-count">{{ backlinks.length }}</span>
|
||||
</h3>
|
||||
<div class="backlinks-grid">
|
||||
<router-link
|
||||
v-for="link in backlinks"
|
||||
:key="`${link.type}-${link.id}`"
|
||||
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
|
||||
class="backlink-card"
|
||||
>
|
||||
<span :class="['backlink-type-badge', `badge-${link.type}`]">{{ link.type }}</span>
|
||||
<span class="backlink-title">{{ link.title || "Untitled" }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else>Task not found.</p>
|
||||
</main>
|
||||
<TableOfContents
|
||||
v-if="store.currentTask?.body"
|
||||
:body="store.currentTask.body"
|
||||
class="toc-sidebar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ShareDialog
|
||||
v-if="showShare && store.currentTask"
|
||||
resource-type="note"
|
||||
:resource-id="store.currentTask.id"
|
||||
:resource-title="store.currentTask.title || '(untitled)'"
|
||||
@close="showShare = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/viewer-shared.css" />
|
||||
<style scoped>
|
||||
.viewer-layout {
|
||||
display: flex;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
gap: 2rem;
|
||||
}
|
||||
.viewer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 1100px;
|
||||
margin: 2rem 0;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
.toc-sidebar {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.toc-sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.83rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.meta-sep {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.due-date {
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
.due-date.overdue {
|
||||
color: var(--fs-overdue);
|
||||
font-weight: 500;
|
||||
}
|
||||
.task-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.task-meta-item {
|
||||
font-size: 0.78rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.task-meta-recurrence {
|
||||
color: var(--fs-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Sub-tasks */
|
||||
.subtasks {
|
||||
margin-top: 2rem;
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.subtasks-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.subtasks-title {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
.subtasks-progress {
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.subtasks-pct {
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.subtasks-track {
|
||||
height: 4px;
|
||||
background: var(--fs-surface-raised);
|
||||
border-radius: 2px;
|
||||
margin-bottom: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.subtasks-fill {
|
||||
height: 100%;
|
||||
background: var(--fs-status-done);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.subtasks-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.subtask-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
}
|
||||
.subtask-row:hover {
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
.sub-dot {
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: transform 0.1s, opacity 0.1s;
|
||||
}
|
||||
.sub-dot:hover {
|
||||
transform: scale(1.25);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.dot-todo {
|
||||
background: transparent;
|
||||
border: 2px solid var(--fs-text-tertiary);
|
||||
}
|
||||
.dot-in-progress {
|
||||
background: var(--fs-status-in-progress);
|
||||
}
|
||||
.dot-done {
|
||||
background: var(--fs-status-done);
|
||||
}
|
||||
.dot-cancelled {
|
||||
background: var(--fs-text-tertiary);
|
||||
}
|
||||
.sub-title {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
color: var(--fs-text-primary);
|
||||
text-decoration: none;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sub-title:hover {
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
.sub-title.sub-done {
|
||||
color: var(--fs-text-tertiary);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.sub-due {
|
||||
font-size: 0.75rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.backlinks {
|
||||
margin-top: 2.5rem;
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
.backlinks-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--fs-text-tertiary);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.backlinks-count {
|
||||
margin-left: 0.2rem;
|
||||
font-size: 0.72rem;
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 999px;
|
||||
padding: 0 0.4rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.backlinks-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.backlink-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--fs-radius-lg);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
text-decoration: none;
|
||||
color: var(--fs-text-primary);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.backlink-card:hover {
|
||||
border-color: color-mix(in srgb, var(--fs-accent) 50%, transparent);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
.backlink-type-badge {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 500;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.badge-note {
|
||||
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
|
||||
color: var(--fs-accent);
|
||||
border: 1px solid color-mix(in srgb, var(--fs-accent) 25%, transparent);
|
||||
}
|
||||
.badge-task {
|
||||
background: color-mix(in srgb, #f59e0b 12%, transparent);
|
||||
color: #d97706;
|
||||
border: 1px solid color-mix(in srgb, #f59e0b 30%, transparent);
|
||||
}
|
||||
.backlink-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Skeleton loader ── */
|
||||
@keyframes skel-shine {
|
||||
to { background-position: 200% center; }
|
||||
}
|
||||
.viewer-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
.skel-btn,
|
||||
.skel-title,
|
||||
.skel-meta,
|
||||
.skel-badges,
|
||||
.skel-line {
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--fs-surface-raised) 25%,
|
||||
color-mix(in srgb, var(--fs-text-tertiary) 18%, var(--fs-surface-raised)) 50%,
|
||||
var(--fs-surface-raised) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skel-shine 1.5s ease infinite;
|
||||
}
|
||||
.skel-toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.skel-btn { width: 70px; height: 32px; }
|
||||
.skel-btn--wide { width: 90px; }
|
||||
.skel-title { height: 2.2rem; width: 65%; border-radius: var(--fs-radius-lg); }
|
||||
.skel-meta { height: 0.85rem; width: 45%; }
|
||||
.skel-badges { height: 1.6rem; width: 30%; border-radius: 999px; }
|
||||
.skel-line { height: 0.9rem; }
|
||||
.skel-line--short { width: 50%; }
|
||||
.skel-line--medium { width: 78%; }
|
||||
|
||||
/* ── Goal block + auto-summary banner ─────────────────────────────────────── */
|
||||
.task-goal-display {
|
||||
border-left: 2px solid var(--fs-border-color);
|
||||
padding: 0.4rem 0 0.4rem 0.9rem;
|
||||
margin: 0.75rem 0 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.goal-label {
|
||||
font-family: var(--fs-font-display);
|
||||
font-style: italic;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fs-text-tertiary);
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
.goal-text {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.45;
|
||||
color: var(--fs-text-primary);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,441 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { apiGet, apiPost, apiPut, apiDelete, apiErrorMessage } from "@/api/client";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import type { User } from "@/types/auth";
|
||||
import { fmtDate } from "@/utils/dateFormat";
|
||||
|
||||
interface Invitation {
|
||||
id: number;
|
||||
email: string;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const toastStore = useToastStore();
|
||||
|
||||
const users = ref<User[]>([]);
|
||||
const registrationOpen = ref(false);
|
||||
const loading = ref(true);
|
||||
const toggling = ref(false);
|
||||
const confirmDeleteId = ref<number | null>(null);
|
||||
const deleting = ref<number | null>(null);
|
||||
|
||||
const inviteEmail = ref("");
|
||||
const sendingInvite = ref(false);
|
||||
const invitations = ref<Invitation[]>([]);
|
||||
const revokingId = ref<number | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchUsers(), fetchRegistration(), fetchInvitations()]);
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
async function fetchUsers() {
|
||||
try {
|
||||
const data = await apiGet<{ users: User[] }>("/api/admin/users");
|
||||
users.value = data.users;
|
||||
} catch {
|
||||
toastStore.show("Failed to load users", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRegistration() {
|
||||
try {
|
||||
const data = await apiGet<{ open: boolean }>("/api/admin/registration");
|
||||
registrationOpen.value = data.open;
|
||||
} catch {
|
||||
// Ignore — will default to false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchInvitations() {
|
||||
try {
|
||||
const data = await apiGet<{ invitations: Invitation[] }>("/api/admin/invitations");
|
||||
invitations.value = data.invitations;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function sendInvite() {
|
||||
const email = inviteEmail.value.trim().toLowerCase();
|
||||
if (!email) return;
|
||||
sendingInvite.value = true;
|
||||
try {
|
||||
await apiPost("/api/admin/invitations", { email });
|
||||
toastStore.show(`Invitation sent to ${email}`);
|
||||
inviteEmail.value = "";
|
||||
await fetchInvitations();
|
||||
} catch (e: unknown) {
|
||||
toastStore.show(apiErrorMessage(e, "Failed to send invitation"), "error");
|
||||
} finally {
|
||||
sendingInvite.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeInvitation(id: number) {
|
||||
revokingId.value = id;
|
||||
try {
|
||||
await apiDelete(`/api/admin/invitations/${id}`);
|
||||
invitations.value = invitations.value.filter((inv) => inv.id !== id);
|
||||
toastStore.show("Invitation revoked");
|
||||
} catch {
|
||||
toastStore.show("Failed to revoke invitation", "error");
|
||||
} finally {
|
||||
revokingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRegistration() {
|
||||
toggling.value = true;
|
||||
try {
|
||||
const data = await apiPut<{ open: boolean }>("/api/admin/registration", {
|
||||
open: !registrationOpen.value,
|
||||
});
|
||||
registrationOpen.value = data.open;
|
||||
toastStore.show(data.open ? "Registration opened" : "Registration closed");
|
||||
} catch {
|
||||
toastStore.show("Failed to update registration setting", "error");
|
||||
} finally {
|
||||
toggling.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(userId: number) {
|
||||
if (confirmDeleteId.value === userId) {
|
||||
deleteUser(userId);
|
||||
} else {
|
||||
confirmDeleteId.value = userId;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDelete() {
|
||||
confirmDeleteId.value = null;
|
||||
}
|
||||
|
||||
async function deleteUser(userId: number) {
|
||||
confirmDeleteId.value = null;
|
||||
deleting.value = userId;
|
||||
try {
|
||||
await apiDelete(`/api/admin/users/${userId}`);
|
||||
users.value = users.value.filter((u) => u.id !== userId);
|
||||
toastStore.show("User deleted");
|
||||
} catch (e: unknown) {
|
||||
toastStore.show(apiErrorMessage(e, "Failed to delete user"), "error");
|
||||
} finally {
|
||||
deleting.value = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="users-page">
|
||||
<h1>User Management</h1>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Registration</h2>
|
||||
<div class="registration-row">
|
||||
<div class="registration-info">
|
||||
<p class="registration-status">
|
||||
Registration is currently
|
||||
<strong :class="registrationOpen ? 'text-success' : 'text-muted'">
|
||||
{{ registrationOpen ? "open" : "closed" }}
|
||||
</strong>
|
||||
</p>
|
||||
<p class="field-hint">
|
||||
When closed, new users can only be added by an administrator.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn-primary btn-toggle"
|
||||
:class="registrationOpen ? 'btn-toggle-close' : 'btn-toggle-open'"
|
||||
@click="toggleRegistration"
|
||||
:disabled="toggling"
|
||||
>
|
||||
{{ toggling ? "Updating..." : registrationOpen ? "Close Registration" : "Open Registration" }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Invite User</h2>
|
||||
<form class="invite-form" @submit.prevent="sendInvite">
|
||||
<input
|
||||
v-model="inviteEmail"
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
class="input invite-input"
|
||||
required
|
||||
:disabled="sendingInvite"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
:disabled="sendingInvite || !inviteEmail.trim()"
|
||||
>
|
||||
{{ sendingInvite ? "Sending..." : "Send Invite" }}
|
||||
</button>
|
||||
</form>
|
||||
<p class="field-hint">Send an invitation link to allow someone to register, even when public registration is closed.</p>
|
||||
|
||||
<div v-if="invitations.length > 0" class="invite-list">
|
||||
<h3>Pending Invitations</h3>
|
||||
<table class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th class="hide-mobile">Sent</th>
|
||||
<th class="hide-mobile">Expires</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="inv in invitations" :key="inv.id">
|
||||
<td class="cell-email">{{ inv.email }}</td>
|
||||
<td class="hide-mobile cell-date">{{ fmtDate(inv.created_at) }}</td>
|
||||
<td class="hide-mobile cell-date">{{ fmtDate(inv.expires_at) }}</td>
|
||||
<td class="cell-actions">
|
||||
<button
|
||||
class="btn-ghost btn-compact"
|
||||
@click="revokeInvitation(inv.id)"
|
||||
:disabled="revokingId !== null"
|
||||
>
|
||||
{{ revokingId === inv.id ? "Revoking..." : "Revoke" }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Users</h2>
|
||||
|
||||
<div v-if="loading" class="loading-msg">Loading users...</div>
|
||||
|
||||
<div v-else-if="users.length === 0" class="empty-msg">No users found.</div>
|
||||
|
||||
<table v-else class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th class="hide-mobile">Email</th>
|
||||
<th>Role</th>
|
||||
<th class="hide-mobile">Joined</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in users" :key="u.id">
|
||||
<td class="cell-username">{{ u.username }}</td>
|
||||
<td class="hide-mobile cell-email">{{ u.email || "—" }}</td>
|
||||
<td>
|
||||
<span class="role-badge" :class="u.role === 'admin' ? 'role-admin' : 'role-user'">
|
||||
{{ u.role }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="hide-mobile cell-date">{{ fmtDate(u.created_at) }}</td>
|
||||
<td class="cell-actions">
|
||||
<template v-if="u.id === authStore.user?.id">
|
||||
<span class="you-label">You</span>
|
||||
</template>
|
||||
<template v-else-if="confirmDeleteId === u.id">
|
||||
<button
|
||||
class="btn-danger btn-compact"
|
||||
@click="confirmDelete(u.id)"
|
||||
:disabled="deleting !== null"
|
||||
>
|
||||
{{ deleting === u.id ? "Deleting..." : "Confirm" }}
|
||||
</button>
|
||||
<button class="btn-ghost btn-compact" @click="cancelDelete">Cancel</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
class="btn-ghost btn-compact"
|
||||
@click="confirmDelete(u.id)"
|
||||
:disabled="deleting !== null"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.users-page {
|
||||
max-width: 1200px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
.users-page h1 {
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
.settings-section {
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.settings-section h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Invite form */
|
||||
.invite-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.invite-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
font-size: 0.95rem;
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.invite-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
.invite-list {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.invite-list h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
|
||||
/* Registration toggle */
|
||||
.registration-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.registration-info {
|
||||
flex: 1;
|
||||
}
|
||||
.registration-status {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.text-success {
|
||||
color: var(--fs-success);
|
||||
}
|
||||
.text-muted {
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.field-hint {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
/* The one genuine override: 'close registration' must NOT read as the
|
||||
primary action it sits on. Scoped, so it beats the shared variant. */
|
||||
.btn-toggle-close {
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.btn-toggle-close:hover:not(:disabled) {
|
||||
border-color: var(--fs-warning);
|
||||
color: var(--fs-warning);
|
||||
}
|
||||
|
||||
/* Users table */
|
||||
.loading-msg,
|
||||
.empty-msg {
|
||||
text-align: center;
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.users-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.users-table th {
|
||||
text-align: left;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--fs-text-tertiary);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.users-table td {
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.users-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.cell-username {
|
||||
font-weight: 600;
|
||||
}
|
||||
.cell-email {
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
.cell-date {
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.cell-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Role badges */
|
||||
.role-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
}
|
||||
.role-admin {
|
||||
color: var(--fs-accent);
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
}
|
||||
.role-user {
|
||||
color: var(--fs-text-tertiary);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.you-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.registration-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.btn-toggle {
|
||||
width: 100%;
|
||||
}
|
||||
.invite-form {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "scribe",
|
||||
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
|
||||
"version": "0.1.38",
|
||||
"version": "0.1.44",
|
||||
"author": { "name": "Bryan Van Deusen" },
|
||||
"mcpServers": {
|
||||
"scribe": {
|
||||
|
||||
+18
-2
@@ -52,8 +52,24 @@ On install you'll be asked for:
|
||||
but never stop it; silent when nothing is recorded, which is most of the time.
|
||||
Two framings: a REUSE menu (similar/nearby records), and a SYNC nudge when a
|
||||
snippet records the exact file being edited — "updating the record is part of
|
||||
the edit" — each with its own once-per-session dedup.
|
||||
Toggle in **Settings → Knowledge auto-inject**.
|
||||
the edit" — each with its own once-per-session dedup. A third, ledger-fed
|
||||
line names a duplicate family (no canon) or a canon recorded elsewhere for
|
||||
the names being written (its own dedup channel, `exclude_derive`).
|
||||
Fail-open but not fail-silent: a configured instance that does not answer
|
||||
in time is said, once per outage ("Scribe did not answer … this write went
|
||||
UNCHECKED"), so a session can tell "checked, nothing there" from "never
|
||||
checked"; an answer clears the marker. The local by-name arm needs no
|
||||
server and always runs. Toggle in **Settings → Knowledge auto-inject**.
|
||||
- `hooks/hooks.json` → PostToolUse hook on `Bash`
|
||||
(`hooks/scribe_after_write.sh`): code written through sed/heredocs/scripts
|
||||
never reaches the PreToolUse hook, so this one diffs the working tree after
|
||||
every Bash call (per-session path+blob snapshot; one `git status` when
|
||||
nothing changed) and runs the same arms on the definitions just written,
|
||||
through the same endpoint and the same dedup channels. `additionalContext`
|
||||
only; never blocks, and shares the pre-write hook's once-per-outage "did not
|
||||
answer" line (8 s budget here — it runs after the tool, so it gates
|
||||
nothing). The extractor, the prose/data skip list, the local by-name
|
||||
duplicate arm and the outage line are shared in `hooks/scribe_defs.sh`.
|
||||
- `skills/` → the universal process-skills, surfaced by description match.
|
||||
- `hooks/scribe_sync_processes.sh` (a 2nd SessionStart hook) + the `/scribe:sync`
|
||||
command → generate `~/.claude/skills/scribe-proc-*` stubs from your Scribe
|
||||
|
||||
@@ -34,6 +34,17 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_after_write.sh\""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env bash
|
||||
# Scribe plugin — PostToolUse write-path trigger on Bash (#2901).
|
||||
#
|
||||
# scribe_prior_art.sh fires before a Write/Edit TOOL CALL. Code written any
|
||||
# other way — sed, heredocs, python edit scripts, `cat > file` — never reached
|
||||
# it, so a whole class of edits (the ones a long session makes most) got no
|
||||
# prior-art hint, no ledger feed and no duplicate-family warning. This hook
|
||||
# closes that: after EVERY Bash call it asks git what changed in the working
|
||||
# tree since it last looked, and runs the same arms on the definitions that
|
||||
# were just written — the local by-name duplicate arm, the recorded prior-art
|
||||
# arms and the ledger's derive/divergence checks (#2900/#2793), via the same
|
||||
# /api/plugin/prior-art endpoint the pre-write hook uses.
|
||||
#
|
||||
# Post-hoc by a few seconds, in the same moment and the same session: "the
|
||||
# copy just landed; here is its family" — not "an audit found it later".
|
||||
#
|
||||
# Cheap when nothing changed: one `git status`. State per session, beside the
|
||||
# pre-write hook's (its three dedup channels are SHARED, so a family named by
|
||||
# one hook is not named again by the other):
|
||||
# ${TMPDIR:-/tmp}/scribe-afterwrite/<sid>.snap path<TAB>blob-hash of every
|
||||
# dirty/untracked file last seen
|
||||
# ${TMPDIR:-/tmp}/scribe-priorart/<sid>.* the dedup channels
|
||||
#
|
||||
# NEVER BLOCKS. It returns `additionalContext` only (no decision — there is
|
||||
# nothing left to decide, the write already happened). Any failure —
|
||||
# unconfigured, unreachable, not a git repo, malformed — exits 0 in silence.
|
||||
#
|
||||
# Config (same as the other hooks):
|
||||
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
|
||||
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
|
||||
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
|
||||
set -uo pipefail
|
||||
|
||||
command -v jq >/dev/null 2>&1 || exit 0
|
||||
command -v git >/dev/null 2>&1 || exit 0
|
||||
|
||||
# shellcheck source=plugin/hooks/scribe_defs.sh
|
||||
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
|
||||
|
||||
# PostToolUse delivers { session_id, cwd, tool_name, tool_input, tool_response }.
|
||||
event=$(cat 2>/dev/null || true)
|
||||
tool_name=$(printf '%s' "$event" | jq -r '.tool_name // empty' 2>/dev/null) || exit 0
|
||||
[ "$tool_name" = "Bash" ] || exit 0
|
||||
session_id=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || session_id=""
|
||||
event_cwd=$(printf '%s' "$event" | jq -r '.cwd // empty' 2>/dev/null) || event_cwd=""
|
||||
work_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
|
||||
repo_root=$(git -C "$work_dir" rev-parse --show-toplevel 2>/dev/null) || exit 0
|
||||
[ -n "$repo_root" ] || exit 0
|
||||
|
||||
safe_sid=$(printf '%s' "${session_id:-nosession}" | tr -c 'A-Za-z0-9._-' '_')
|
||||
snap_dir="${TMPDIR:-/tmp}/scribe-afterwrite"
|
||||
mkdir -p "$snap_dir" 2>/dev/null || true
|
||||
snap="$snap_dir/${safe_sid}.snap"
|
||||
|
||||
# What is dirty now: every modified / added / untracked path, with the blob
|
||||
# hash of its working-tree content. Hash, not mtime: portable (no stat
|
||||
# flags), exact (a touch is not a change), and untracked files hash the same
|
||||
# way tracked ones do.
|
||||
current=""
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] || continue
|
||||
status=${line:0:2}
|
||||
path=${line:3}
|
||||
case "$status" in
|
||||
D*|*D) continue ;; # a deletion defines nothing
|
||||
esac
|
||||
case "$path" in
|
||||
*" -> "*) path=${path##* -> } ;; # rename: the new name
|
||||
esac
|
||||
# Porcelain quotes paths with special characters; those are skipped rather
|
||||
# than unquoted badly — a filename needing quotes is not where shapes live.
|
||||
case "$path" in
|
||||
\"*) continue ;;
|
||||
esac
|
||||
[ -f "$repo_root/$path" ] || continue
|
||||
sha=$(git -C "$repo_root" hash-object -- "$path" 2>/dev/null) || continue
|
||||
current="${current}${path}"$'\t'"${sha}"$'\n'
|
||||
done < <(git -C "$repo_root" status --porcelain --untracked-files=all 2>/dev/null)
|
||||
|
||||
previous=""
|
||||
[ -f "$snap" ] && previous=$(cat "$snap" 2>/dev/null || true)
|
||||
first_run=0
|
||||
[ -f "$snap" ] || first_run=1
|
||||
# Write the new snapshot NOW, before anything can fail below — the next call
|
||||
# must compare against this tree, whatever happens to this one's hint.
|
||||
printf '%s' "$current" > "$snap" 2>/dev/null || true
|
||||
|
||||
# Changed = a (path, hash) pair not in the previous snapshot. On the very
|
||||
# first call of a session there is no previous snapshot; rather than report
|
||||
# every pre-existing dirty file as "just written", take only files touched in
|
||||
# the last minute — the Bash call that just ran is the likely author.
|
||||
changed=""
|
||||
while IFS=$'\t' read -r path sha; do
|
||||
[ -n "${path:-}" ] || continue
|
||||
if [ "$first_run" = 1 ]; then
|
||||
[ -n "$(find "$repo_root/$path" -mmin -1 2>/dev/null)" ] || continue
|
||||
else
|
||||
case "$previous" in
|
||||
*"${path}"$'\t'"${sha}"*) continue ;;
|
||||
esac
|
||||
fi
|
||||
scribe_skip_path "$path" && continue
|
||||
changed="${changed}${path}"$'\n'
|
||||
done <<< "$current"
|
||||
[ -n "$changed" ] || exit 0
|
||||
|
||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
||||
case "$url" in *'${'*) url="" ;; esac
|
||||
case "$token" in *'${'*) token="" ;; esac
|
||||
repo=$(git -C "$repo_root" remote get-url origin 2>/dev/null || true)
|
||||
repo_q=""
|
||||
if [ -n "$repo" ]; then
|
||||
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
|
||||
[ -n "$enc" ] && repo_q="&repo=${enc}"
|
||||
fi
|
||||
|
||||
# The dedup channels are the PRE-write hook's files, on purpose (see header).
|
||||
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
|
||||
mkdir -p "$state_dir" 2>/dev/null || true
|
||||
idfile="$state_dir/${safe_sid}.ids"
|
||||
syncfile="$state_dir/${safe_sid}.sync.ids"
|
||||
derivefile="$state_dir/${safe_sid}.derive.ids"
|
||||
|
||||
combined=""
|
||||
n_files=0
|
||||
while IFS= read -r rel_path; do
|
||||
[ -n "${rel_path:-}" ] || continue
|
||||
# A Bash call that rewrote many files is a refactor or a generator, not a
|
||||
# shape being instantiated; four is enough to name what matters.
|
||||
n_files=$((n_files + 1))
|
||||
[ "$n_files" -le 4 ] || break
|
||||
file_path="$repo_root/$rel_path"
|
||||
|
||||
# The code just written: the ADDED lines of the uncommitted diff for a
|
||||
# tracked file (sed, not cut: this strips one marker char per line, it is
|
||||
# not a payload cap), the whole file when untracked.
|
||||
if git -C "$repo_root" ls-files --error-unmatch -- "$rel_path" >/dev/null 2>&1; then
|
||||
code=$(git -C "$repo_root" diff -U0 -- "$rel_path" 2>/dev/null | grep '^+' | grep -v '^+++' | sed 's/^+//') || code=""
|
||||
else
|
||||
code=$(cat "$file_path" 2>/dev/null) || code=""
|
||||
fi
|
||||
[ -n "$code" ] || continue
|
||||
names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12) || names=""
|
||||
# Nothing DEFINED in what was written (prose, data, a call-site edit) →
|
||||
# nothing to say; the arms are about shapes.
|
||||
[ -n "$names" ] || continue
|
||||
|
||||
local_lines=$(scribe_local_dups "$repo_root" "$rel_path" <<< "$names") || local_lines=""
|
||||
local_context=""
|
||||
if [ -n "$local_lines" ]; then
|
||||
local_context="> Already defined elsewhere in this repo — \`${rel_path}\` (just written) adds another copy; check before keeping it (\`git grep\` shown; a nudge, not a gate):"$'\n'"${local_lines}"
|
||||
fi
|
||||
|
||||
context=""
|
||||
body=""
|
||||
reached="" # "" unconfigured (no call owed) · 1 answered · 0 did not
|
||||
unreached_context=""
|
||||
if [ -n "$url" ] && [ -n "$token" ]; then
|
||||
q=$(printf '%s' "$code" | head -c 1200)
|
||||
path_enc=$(printf '%s' "$rel_path" | jq -sRr '@uri' 2>/dev/null) || path_enc=""
|
||||
code_enc=$(printf '%s' "$q" | jq -sRr '@uri' 2>/dev/null) || code_enc=""
|
||||
shapes_q=""
|
||||
enc=$(printf '%s\n' "$names" \
|
||||
| awk -F'\t' 'NF>=2 {printf "%s%s:%s", (n++?",":""), $1, $2}' \
|
||||
| jq -sRr '@uri' 2>/dev/null) || enc=""
|
||||
[ -n "$enc" ] && shapes_q="&shapes=${enc}"
|
||||
exclude_q=""; sync_exclude_q=""; derive_exclude_q=""
|
||||
if [ -f "$idfile" ]; then
|
||||
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
|
||||
[ -n "$seen" ] && exclude_q="&exclude_ids=${seen}"
|
||||
fi
|
||||
if [ -f "$syncfile" ]; then
|
||||
sync_seen=$(tr '\n' ',' < "$syncfile" 2>/dev/null | sed 's/,$//')
|
||||
[ -n "$sync_seen" ] && sync_exclude_q="&exclude_sync_ids=${sync_seen}"
|
||||
fi
|
||||
if [ -f "$derivefile" ]; then
|
||||
derive_seen=$(tr '\n' ',' < "$derivefile" 2>/dev/null | sed 's/,$//' | jq -sRr '@uri' 2>/dev/null) || derive_seen=""
|
||||
[ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}"
|
||||
fi
|
||||
if [ -n "$path_enc" ]; then
|
||||
# 8s, not the pre-write hook's 5: this hook runs AFTER the tool, so it
|
||||
# gates nothing the session is waiting on, and the first prior-art call
|
||||
# after a redeploy is a cold start (embedding warm-up, ~4.6s observed)
|
||||
# that a 4s cap turned into a silent fail-open — the one write a
|
||||
# session most wants the ledger's word on lost it.
|
||||
reached=1
|
||||
body=$(curl -fsS --max-time 8 \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${derive_exclude_q}${shapes_q}" 2>/dev/null) || { body=""; reached=0; }
|
||||
# A call that was owed and didn't come back is said, once per outage
|
||||
# (#2932) — shared marker with the pre-write hook, so one outage is one
|
||||
# line however the code was written.
|
||||
if [ "$reached" = 1 ]; then
|
||||
scribe_reached "$state_dir" "$safe_sid"
|
||||
else
|
||||
unreached_context=$(scribe_unreached "$state_dir" "$safe_sid" 8 "$rel_path")
|
||||
fi
|
||||
fi
|
||||
if [ -n "$body" ]; then
|
||||
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || context=""
|
||||
if [ -n "$context" ]; then
|
||||
printf '%s' "$body" | jq -r '((.note_ids // []) - (.sync_note_ids // []))[]?' 2>/dev/null >> "$idfile" || true
|
||||
printf '%s' "$body" | jq -r '(.sync_note_ids // [])[]?' 2>/dev/null >> "$syncfile" || true
|
||||
printf '%s' "$body" | jq -r '(.derive_keys // [])[]?' 2>/dev/null >> "$derivefile" || true
|
||||
# Several files in one call may name the same family: keep each
|
||||
# token once, so the next request's exclude list stays exact.
|
||||
for f in "$idfile" "$syncfile" "$derivefile"; do
|
||||
[ -s "$f" ] && { sort -u -o "$f" "$f" 2>/dev/null || true; }
|
||||
done
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# The record nudge (#2664), same gate as the pre-write hook: duplication
|
||||
# demonstrated locally AND nothing recorded for it — and (#2932) never on a
|
||||
# call that did not answer; "nothing recorded" is a claim only an answer
|
||||
# can back.
|
||||
if [ -n "$local_lines" ] && [ "$reached" != 0 ]; then
|
||||
n_recorded=$(printf '%s' "$body" | jq -r '.note_ids | length' 2>/dev/null) || n_recorded=0
|
||||
if [ "${n_recorded:-0}" = "0" ] || [ "$n_recorded" = "" ]; then
|
||||
local_context="${local_context}"$'\n'"> None of those existing copies is recorded in Scribe. If the version just written is the canonical one — or this edit is consolidating the copies — record it now with create_snippet so the next session is offered it instead of writing another copy."
|
||||
fi
|
||||
fi
|
||||
|
||||
part="$local_context"
|
||||
if [ -n "$context" ]; then
|
||||
[ -n "$part" ] && part="${part}"$'\n'
|
||||
part="${part}${context}"
|
||||
fi
|
||||
if [ -n "$unreached_context" ]; then
|
||||
[ -n "$part" ] && part="${part}"$'\n'
|
||||
part="${part}${unreached_context}"
|
||||
fi
|
||||
[ -n "$part" ] || continue
|
||||
[ -n "$combined" ] && combined="${combined}"$'\n'
|
||||
combined="${combined}${part}"
|
||||
done <<< "$changed"
|
||||
|
||||
[ -n "$combined" ] || exit 0
|
||||
jq -n --arg c "$combined" \
|
||||
'{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $c}}'
|
||||
exit 0
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env bash
|
||||
# shellcheck shell=bash
|
||||
# Scribe plugin — the pieces the two write-path hooks share (#2901).
|
||||
#
|
||||
# scribe_prior_art.sh fires BEFORE a Write/Edit tool call; scribe_after_write.sh
|
||||
# fires AFTER a Bash tool call and diffs the working tree, so code written by
|
||||
# sed/heredocs/scripts gets the same prior-art and ledger checks. Both need the
|
||||
# same three things, kept here so they cannot drift apart:
|
||||
#
|
||||
# scribe_skip_path PATH formats that hold prose or data, not shapes
|
||||
# scribe_defs stdin code → "kind<TAB>name" per definition
|
||||
# scribe_local_dups ROOT REL "kind<TAB>name" lines on stdin → the by-name
|
||||
# local-duplicate lines (ARM 1, #2280)
|
||||
# scribe_unreached STATE SID SECS REL the "Scribe didn't answer" line, once
|
||||
# per outage (#2932) — or nothing, if said lately
|
||||
# scribe_reached STATE SID the server answered: the next outage speaks again
|
||||
#
|
||||
# Sourced, not executed: `. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"`.
|
||||
|
||||
# Skip formats that hold prose or data rather than reusable code. Purely to
|
||||
# avoid a pointless round-trip — the server would return nothing for these
|
||||
# anyway. Config formats are NOT skipped: a CI workflow or a compose file is
|
||||
# often exactly the thing worth reusing.
|
||||
scribe_skip_path() {
|
||||
case "$1" in
|
||||
*.md|*.mdx|*.txt|*.rst|*.json|*.lock|*.log|*.csv|*.tsv|*.svg|*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf)
|
||||
return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# kind<TAB>name for each thing a piece of code DEFINES, in source order. One
|
||||
# program, two consumers: the local duplicate arm (every definition in the
|
||||
# payload) and the ledger feed (#2791, below: the definitions being written,
|
||||
# or the one enclosing an Edit). Rule-for-rule mirrored by the server's
|
||||
# services/coverage.py extract_shapes — ledger rows are keyed by what THAT
|
||||
# sees, so the two must agree on what counts as a definition.
|
||||
scribe_defs() {
|
||||
awk '
|
||||
{
|
||||
# CSS class definition: .name { or .name,
|
||||
if (match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/)) {
|
||||
t = $0; sub(/^[[:space:]]*\./, "", t); sub(/[[:space:]]*[,{].*$/, "", t)
|
||||
if (t != "") print "css\t" t; next
|
||||
}
|
||||
line = $0; sub(/^[[:space:]]+/, "", line)
|
||||
# Strip leading declaration modifiers so the definition keyword is the
|
||||
# first word regardless of language (export/pub/private/suspend/...).
|
||||
sub(/^((pub(\([a-z]+\))?|export|default|private|internal|protected|public|static|suspend|async|open|sealed|data|abstract|final|inline|unsafe|extern|override)[[:space:]]+)*/, "", line)
|
||||
# Go method with receiver: func (r *T) Name(
|
||||
if (match(line, /^func[[:space:]]*\([^)]*\)[[:space:]]*[A-Za-z_]/)) {
|
||||
t = line; sub(/^func[[:space:]]*\([^)]*\)[[:space:]]*/, "", t)
|
||||
sub(/[^A-Za-z0-9_].*$/, "", t)
|
||||
if (t != "") print "sym\t" t; next
|
||||
}
|
||||
# Keyword-announced definitions, functions and named types alike.
|
||||
# Dunders are skipped: every class defines __init__, so "already defined
|
||||
# in N other files" is guaranteed noise for them — and noise is what
|
||||
# teaches sessions to skip the hint.
|
||||
if (match(line, /^(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+[A-Za-z_$]/)) {
|
||||
t = line; sub(/^[a-z]+[[:space:]]+/, "", t)
|
||||
sub(/[^A-Za-z0-9_$].*$/, "", t)
|
||||
# `type` defines only when something follows the name (= or {); an
|
||||
# import specifier `type Foo,` is the same two words and defines
|
||||
# nothing (mirror of coverage.py, #2904).
|
||||
if (line ~ /^type[[:space:]]/) {
|
||||
rest = line; sub(/^type[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/, "", rest)
|
||||
if (rest !~ /[={]/) next
|
||||
}
|
||||
if (t != "" && t !~ /^__.*__$/) print "sym\t" t; next
|
||||
}
|
||||
# Arrow/expression assignment: const name = (…) / let name = async (
|
||||
if (match(line, /^(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?[(<]/)) {
|
||||
t = line; sub(/^(const|let)[[:space:]]+/, "", t)
|
||||
sub(/[^A-Za-z0-9_$].*$/, "", t)
|
||||
if (t != "") print "sym\t" t; next
|
||||
}
|
||||
}
|
||||
' 2>/dev/null
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ARM 1 — BY NAME, LOCALLY (#2280). Does a definition of this already exist?
|
||||
#
|
||||
# The recorded arms ask Scribe what was RECORDED; the ledger arm (#2900) asks
|
||||
# what a BOUND repo's ledger knows. A helper nobody recorded, in a repo nobody
|
||||
# bound, is invisible to both — which is how `.btn-primary` came to be defined
|
||||
# four times, already diverged. This arm asks the one question only the
|
||||
# developer's machine can answer, inside the repo, holding the code about to
|
||||
# be written: no index, no storage, no server — it runs even on an install
|
||||
# that has never configured Scribe.
|
||||
#
|
||||
# Definition-shaped patterns only. Grepping for bare occurrences would match
|
||||
# every CALL site and drown the real finding — and a hint that is mostly noise
|
||||
# is one people learn to skip, which is worse than none. ALL code, not a
|
||||
# language shortlist (#2682): the same keyword family scribe_defs announces.
|
||||
#
|
||||
# $1 repo root, $2 repo-relative path of the file being written (excluded from
|
||||
# the grep — it would always match itself on an Edit). Definitions on stdin.
|
||||
# Prints one "> - `name` is already defined in N other file(s): …" per hit.
|
||||
scribe_local_dups() {
|
||||
local root="$1" rel="$2" kind name pat hits count label files
|
||||
while IFS=$'\t' read -r kind name; do
|
||||
[ -n "${name:-}" ] || continue
|
||||
case "$kind" in
|
||||
css) pat="^[[:space:]]*\.${name}[[:space:]]*[,{]" ;;
|
||||
*) pat="(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+${name}[^A-Za-z0-9_]|func[[:space:]]*\([^)]*\)[[:space:]]*${name}[[:space:]]*\(|(const|let)[[:space:]]+${name}[[:space:]]*=" ;;
|
||||
esac
|
||||
# -I skips binaries; :(exclude) drops the file being written.
|
||||
hits=$(git -C "$root" grep -I -l -E -e "$pat" -- . ":(exclude)${rel}" 2>/dev/null | head -4) || hits=""
|
||||
[ -n "$hits" ] || continue
|
||||
count=$(printf '%s\n' "$hits" | grep -c . 2>/dev/null || echo 0)
|
||||
label=$([ "$kind" = css ] && printf '.%s' "$name" || printf '%s' "$name")
|
||||
files=$(printf '%s' "$hits" | tr '\n' ' ' | sed 's/ $//')
|
||||
printf '> - `%s` is already defined in %s other file(s): %s\n' "$label" "$count" "$files"
|
||||
done
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The blind spot made visible (#2932). Both write-path hooks fail OPEN when the
|
||||
# instance is slow or down — right for noise, wrong for silence: a session
|
||||
# cannot tell "the ledger checked and found nothing" from "the ledger never
|
||||
# answered", and a self-surfacing system cannot afford an invisible miss (the
|
||||
# first write after a redeploy lost its derive line to a 4s cold start and
|
||||
# nobody knew). So a failed call says so — ONCE per outage: the marker holds
|
||||
# the time it last spoke; within ten minutes of that it stays quiet, and a
|
||||
# successful call clears it so the next outage announces itself afresh.
|
||||
# Unconfigured installs never reach this: no URL/token means no call was owed.
|
||||
_SCRIBE_UNREACHED_QUIET=600
|
||||
|
||||
scribe_unreached() {
|
||||
local marker="$1/$2.unreached" now last
|
||||
now=$(date +%s 2>/dev/null) || now=0
|
||||
if [ -f "$marker" ]; then
|
||||
last=$(cat "$marker" 2>/dev/null) || last=0
|
||||
case "$last" in ''|*[!0-9]*) last=0 ;; esac
|
||||
[ $((now - last)) -lt "$_SCRIBE_UNREACHED_QUIET" ] && return 0
|
||||
fi
|
||||
printf '%s' "$now" > "$marker" 2>/dev/null || true
|
||||
printf '> Scribe did not answer the prior-art check for `%s` within %ss — this write went UNCHECKED against the record and the shape ledger (the local by-name arm, if it spoke above, needed no server). If the name matters, check it yourself: `search` for the concept, `list_shapes(project_id, path=…)` for the ledger. Said once per outage; if it keeps happening the instance is slow or down.' "$4" "$3"
|
||||
}
|
||||
|
||||
scribe_reached() {
|
||||
rm -f "$1/$2.unreached" 2>/dev/null || true
|
||||
}
|
||||
@@ -51,14 +51,12 @@ code=$(printf '%s' "$event" | jq -r '
|
||||
.tool_input.content // .tool_input.file_content //
|
||||
.tool_input.new_string // .tool_input.new_str // empty' 2>/dev/null) || code=""
|
||||
|
||||
# Skip formats that hold prose or data rather than reusable code. Purely to
|
||||
# avoid a pointless round-trip — the server would return nothing for these
|
||||
# anyway. Config formats are NOT skipped: a CI workflow or a compose file is
|
||||
# often exactly the thing worth reusing.
|
||||
case "$file_path" in
|
||||
*.md|*.mdx|*.txt|*.rst|*.json|*.lock|*.log|*.csv|*.tsv|*.svg|*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf)
|
||||
exit 0 ;;
|
||||
esac
|
||||
# Shared with the after-write hook (#2901): the prose/data skip list, the
|
||||
# definition extractor and the local by-name duplicate arm live in
|
||||
# scribe_defs.sh so the two hooks cannot drift apart.
|
||||
# shellcheck source=plugin/hooks/scribe_defs.sh
|
||||
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
|
||||
scribe_skip_path "$file_path" && exit 0
|
||||
|
||||
# Snippet locations are recorded repo-relative, so send a repo-relative path —
|
||||
# an absolute one would simply match nothing. Resolved BEFORE the config gate
|
||||
@@ -73,78 +71,8 @@ if [ -n "$repo_root" ]; then
|
||||
esac
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ARM 1 — BY NAME, LOCALLY (#2280). Does a definition of this already exist?
|
||||
#
|
||||
# The other two arms ask Scribe what was RECORDED. Scribe has never read a line
|
||||
# of the codebase, so a helper nobody thought to record is invisible to them —
|
||||
# which is how `.btn-primary` came to be defined four times, in four scoped
|
||||
# stylesheets, already diverged. It was never a snippet, so no threshold and no
|
||||
# query rewrite could ever have surfaced it.
|
||||
#
|
||||
# This arm closes that by asking the only question the record cannot answer,
|
||||
# in the only place that can: the hook already runs on the developer's machine,
|
||||
# inside the repo, holding the code about to be written. No index, no storage,
|
||||
# no staleness, and no server — it deliberately runs even on an install that
|
||||
# has never configured Scribe.
|
||||
#
|
||||
# Definition-shaped patterns only. Grepping for bare occurrences would match
|
||||
# every CALL site and drown the real finding — and a hint that is mostly noise
|
||||
# is one people learn to skip, which is worse than none.
|
||||
#
|
||||
# ALL code, not a language shortlist (#2682): the detector was born covering
|
||||
# only the languages of the repo it was written in, which silently amputated
|
||||
# this whole arm — and the record nudge gated on it — for every Go/Kotlin/Rust
|
||||
# project. Definitions are announced by a small keyword family across
|
||||
# languages (func/fun/fn/function/def/sub · class/struct/trait/interface/
|
||||
# enum/object/protocol/type), so one modifier-strip + keyword match covers
|
||||
# them all. Known out of scope: keyword-less declaration syntax (C/Java/Dart
|
||||
# `ReturnType name(...)`) needs a real parser, and `impl` blocks are excluded
|
||||
# because several per type is normal Rust, not duplication.
|
||||
# ---------------------------------------------------------------------------
|
||||
# kind<TAB>name for each thing a piece of code DEFINES, in source order. One
|
||||
# program, two consumers: the local duplicate arm (every definition in the
|
||||
# payload) and the ledger feed (#2791, below: the definitions being written,
|
||||
# or the one enclosing an Edit). Rule-for-rule mirrored by the server's
|
||||
# services/coverage.py extract_shapes — ledger rows are keyed by what THAT
|
||||
# sees, so the two must agree on what counts as a definition.
|
||||
scribe_defs() {
|
||||
awk '
|
||||
{
|
||||
# CSS class definition: .name { or .name,
|
||||
if (match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/)) {
|
||||
t = $0; sub(/^[[:space:]]*\./, "", t); sub(/[[:space:]]*[,{].*$/, "", t)
|
||||
if (t != "") print "css\t" t; next
|
||||
}
|
||||
line = $0; sub(/^[[:space:]]+/, "", line)
|
||||
# Strip leading declaration modifiers so the definition keyword is the
|
||||
# first word regardless of language (export/pub/private/suspend/...).
|
||||
sub(/^((pub(\([a-z]+\))?|export|default|private|internal|protected|public|static|suspend|async|open|sealed|data|abstract|final|inline|unsafe|extern|override)[[:space:]]+)*/, "", line)
|
||||
# Go method with receiver: func (r *T) Name(
|
||||
if (match(line, /^func[[:space:]]*\([^)]*\)[[:space:]]*[A-Za-z_]/)) {
|
||||
t = line; sub(/^func[[:space:]]*\([^)]*\)[[:space:]]*/, "", t)
|
||||
sub(/[^A-Za-z0-9_].*$/, "", t)
|
||||
if (t != "") print "sym\t" t; next
|
||||
}
|
||||
# Keyword-announced definitions, functions and named types alike.
|
||||
# Dunders are skipped: every class defines __init__, so "already defined
|
||||
# in N other files" is guaranteed noise for them — and noise is what
|
||||
# teaches sessions to skip the hint.
|
||||
if (match(line, /^(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+[A-Za-z_$]/)) {
|
||||
t = line; sub(/^[a-z]+[[:space:]]+/, "", t)
|
||||
sub(/[^A-Za-z0-9_$].*$/, "", t)
|
||||
if (t != "" && t !~ /^__.*__$/) print "sym\t" t; next
|
||||
}
|
||||
# Arrow/expression assignment: const name = (…) / let name = async (
|
||||
if (match(line, /^(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?[(<]/)) {
|
||||
t = line; sub(/^(const|let)[[:space:]]+/, "", t)
|
||||
sub(/[^A-Za-z0-9_$].*$/, "", t)
|
||||
if (t != "") print "sym\t" t; next
|
||||
}
|
||||
}
|
||||
' 2>/dev/null
|
||||
}
|
||||
|
||||
# ARM 1 — BY NAME, LOCALLY (#2280): does a definition of this already exist
|
||||
# in the repo? (scribe_local_dups in scribe_defs.sh carries the why.)
|
||||
names=""
|
||||
if [ -n "$code" ]; then
|
||||
names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12) || names=""
|
||||
@@ -152,21 +80,8 @@ fi
|
||||
|
||||
local_lines=""
|
||||
if [ -n "$repo_root" ] && [ -n "$names" ]; then
|
||||
while IFS=$'\t' read -r kind name; do
|
||||
[ -n "${name:-}" ] || continue
|
||||
case "$kind" in
|
||||
css) pat="^[[:space:]]*\.${name}[[:space:]]*[,{]" ;;
|
||||
*) pat="(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+${name}[^A-Za-z0-9_]|func[[:space:]]*\([^)]*\)[[:space:]]*${name}[[:space:]]*\(|(const|let)[[:space:]]+${name}[[:space:]]*=" ;;
|
||||
esac
|
||||
# -I skips binaries; :(exclude) drops the file being written, which would
|
||||
# otherwise always match itself on an Edit.
|
||||
hits=$(git -C "$repo_root" grep -I -l -E -e "$pat" -- . ":(exclude)${rel_path}" 2>/dev/null | head -4) || hits=""
|
||||
[ -n "$hits" ] || continue
|
||||
count=$(printf '%s\n' "$hits" | grep -c . 2>/dev/null || echo 0)
|
||||
label=$([ "$kind" = css ] && printf '.%s' "$name" || printf '%s' "$name")
|
||||
files=$(printf '%s' "$hits" | tr '\n' ' ' | sed 's/ $//')
|
||||
local_lines="${local_lines}> - \`${label}\` is already defined in ${count} other file(s): ${files}"$'\n'
|
||||
done <<< "$names"
|
||||
local_lines=$(scribe_local_dups "$repo_root" "$rel_path" <<< "$names") || local_lines=""
|
||||
[ -n "$local_lines" ] && local_lines="${local_lines}"$'\n'
|
||||
fi
|
||||
|
||||
local_context=""
|
||||
@@ -258,14 +173,22 @@ fi
|
||||
# the sync nudge when the recorded file itself is edited later.
|
||||
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
|
||||
mkdir -p "$state_dir" 2>/dev/null || true
|
||||
#
|
||||
# A THIRD channel (#2900): the ledger's derive arm names a duplicate family
|
||||
# (a derive group id) or a canon elsewhere (`canon:<snippet_id>`) for the
|
||||
# shapes being written. Keyed by that token, not a note id, so it dedups on
|
||||
# its own file and a family is named once per session, not at every edit.
|
||||
idfile=""
|
||||
syncfile=""
|
||||
derivefile=""
|
||||
exclude_q=""
|
||||
sync_exclude_q=""
|
||||
derive_exclude_q=""
|
||||
if [ -n "$session_id" ]; then
|
||||
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
||||
idfile="$state_dir/${safe_sid}.ids"
|
||||
syncfile="$state_dir/${safe_sid}.sync.ids"
|
||||
derivefile="$state_dir/${safe_sid}.derive.ids"
|
||||
if [ -f "$idfile" ]; then
|
||||
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
|
||||
[ -n "$seen" ] && exclude_q="&exclude_ids=${seen}"
|
||||
@@ -274,13 +197,26 @@ if [ -n "$session_id" ]; then
|
||||
sync_seen=$(tr '\n' ',' < "$syncfile" 2>/dev/null | sed 's/,$//')
|
||||
[ -n "$sync_seen" ] && sync_exclude_q="&exclude_sync_ids=${sync_seen}"
|
||||
fi
|
||||
if [ -f "$derivefile" ]; then
|
||||
derive_seen=$(tr '\n' ',' < "$derivefile" 2>/dev/null | sed 's/,$//' | jq -sRr '@uri' 2>/dev/null) || derive_seen=""
|
||||
[ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# `|| true`, not `|| exit 0`: an unreachable instance must not discard a local
|
||||
# finding that needed no instance to produce.
|
||||
# Not `|| exit 0`: an unreachable instance must not discard a local finding
|
||||
# that needed no instance to produce. And not silence either (#2932): a call
|
||||
# that was owed and didn't come back is said, once per outage, so the session
|
||||
# knows this write went unchecked.
|
||||
reached=1
|
||||
body=$(curl -fsS --max-time 5 \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${shapes_q}" 2>/dev/null) || body=""
|
||||
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${derive_exclude_q}${shapes_q}" 2>/dev/null) || { body=""; reached=0; }
|
||||
unreached_context=""
|
||||
if [ "$reached" = 1 ]; then
|
||||
scribe_reached "$state_dir" "${safe_sid:-nosession}"
|
||||
else
|
||||
unreached_context=$(scribe_unreached "$state_dir" "${safe_sid:-nosession}" 5 "$rel_path")
|
||||
fi
|
||||
|
||||
context=""
|
||||
if [ -n "$body" ]; then
|
||||
@@ -295,6 +231,9 @@ if [ -n "$body" ]; then
|
||||
if [ -n "$syncfile" ]; then
|
||||
printf '%s' "$body" | jq -r '(.sync_note_ids // [])[]?' 2>/dev/null >> "$syncfile" || true
|
||||
fi
|
||||
if [ -n "$derivefile" ]; then
|
||||
printf '%s' "$body" | jq -r '(.derive_keys // [])[]?' 2>/dev/null >> "$derivefile" || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -304,9 +243,10 @@ fi
|
||||
# noise: the duplication is demonstrated, not guessed. Gated on BOTH sides so
|
||||
# an ordinary new helper (no other copies) and an already-recorded one (the
|
||||
# server spoke) stay nudge-free — a reflex that fires on everything is one
|
||||
# that gets skipped. An unreachable server counts as "nothing recorded": the
|
||||
# local finding needed no server, and the nudge fails open with it.
|
||||
if [ -n "$local_lines" ]; then
|
||||
# that gets skipped. A server that did not ANSWER earns no nudge (#2932): "none
|
||||
# of those copies is recorded" is a claim only an answer can back — the
|
||||
# unreached line says what actually happened instead.
|
||||
if [ -n "$local_lines" ] && [ "$reached" = 1 ]; then
|
||||
n_recorded=$(printf '%s' "$body" | jq -r '.note_ids | length' 2>/dev/null) || n_recorded=0
|
||||
if [ "${n_recorded:-0}" = "0" ] || [ "$n_recorded" = "" ]; then
|
||||
local_context="${local_context}"$'\n'"> None of those existing copies is recorded in Scribe. If the version being written is the canonical one — or this edit is consolidating the copies — record it now with create_snippet (name, code, when-to-reach-for-it, location) so the next session is offered it instead of writing another copy."
|
||||
@@ -321,6 +261,10 @@ if [ -n "$context" ]; then
|
||||
[ -n "$combined" ] && combined="${combined}"$'\n'
|
||||
combined="${combined}${context}"
|
||||
fi
|
||||
if [ -n "$unreached_context" ]; then
|
||||
[ -n "$combined" ] && combined="${combined}"$'\n'
|
||||
combined="${combined}${unreached_context}"
|
||||
fi
|
||||
[ -n "$combined" ] || exit 0
|
||||
|
||||
# No permissionDecision: this is a nudge, not a gate. The write goes ahead.
|
||||
|
||||
@@ -66,7 +66,10 @@ for the operator's work, and as your own working memory across sessions.
|
||||
should read as a map of every shape in it. The backstop still holds:
|
||||
noticing the second copy of anything, or consolidating copies into a shared
|
||||
X, means X gets recorded before that work is finished — which is how a
|
||||
codebase is kept from growing four `.btn-primary` definitions.
|
||||
codebase is kept from growing four `.btn-primary` definitions. The write-path
|
||||
hooks (before a Write/Edit, and after any Bash call that changed the tree)
|
||||
name a known duplicate family or a canon elsewhere for what was just
|
||||
written — act on that line at the write, not at the next audit.
|
||||
- Do **not** keep the operator's rules, plans, or project notes in local
|
||||
memory / CLAUDE.md in parallel with Scribe — Scribe holds the single copy.
|
||||
- **Compact at clean seams** — because you record as you go, a context
|
||||
|
||||
@@ -44,6 +44,14 @@ through recall/auto-inject; this skill is the active reflex around that.
|
||||
it before you go any further. Either it's the helper you were about to
|
||||
duplicate — reuse it and drop yours — or it isn't, and the record needs the new
|
||||
location adding. Both are cheaper now than after the duplicate settles in.
|
||||
- **A `Shape ledger at …` line is the ledger speaking, not the record.** It
|
||||
names a duplicate family ("identical body in N other files, no canon"), a
|
||||
repeated name ("defined in N other files") or a canon elsewhere for a name
|
||||
you just wrote — for edits made through Bash
|
||||
(sed, heredocs, scripts) as much as through Write/Edit. Derive the family or
|
||||
reuse the canon *now*; a family that is convention rather than copies is
|
||||
dismissed with `classify_shapes(..., status="exempt",
|
||||
reason_code="convention-plumbing")`, never ignored.
|
||||
- **A `[records this file]` hint is a duty, not a menu.** When the hint says a
|
||||
snippet records the very file you're editing, the record's freshness is now
|
||||
YOUR edit's responsibility: if the edit changes the recorded shape,
|
||||
|
||||
@@ -85,6 +85,51 @@ the dominant form, `create_snippet` it, migrate the outliers, then classify
|
||||
the rest as instances. Canon is determined from the code; consistency comes
|
||||
from the derivation, not from asking permission.
|
||||
|
||||
## Derive groups are drift, not audit material
|
||||
|
||||
The catalogue exists so the codebase is DRY **from inception**, not as DRY as
|
||||
the last sweep left it. Three surfaces say so without anyone running an audit
|
||||
(milestone 299):
|
||||
|
||||
- **At the write** — the prior-art hint (the Write/Edit hook, and since
|
||||
0.1.39 the after-write hook on Bash, so sed/heredoc/script edits count too)
|
||||
carries a `Shape ledger at <path>` line when a name just written is a known
|
||||
**duplicate family** ("identical body in N other files, no canon"), a
|
||||
**repeated name** ("defined in N other files, no canon") or a
|
||||
**canon elsewhere** ("snippet #N at <path> — reuse, don't redefine"). Act
|
||||
on it *then*: pull the canon and build from it, or derive the family now —
|
||||
`create_snippet` the dominant form, repoint the copies, `classify_shapes`
|
||||
them `instance`. A family is named once per session.
|
||||
- **On arrival** — the coverage line's `standing:` block (shown even when
|
||||
nothing is unclassified) and `derive_new` ("+N new copies since last
|
||||
refresh: .x in <path>") name what drifted since the previous refresh. That
|
||||
is the todo of the moment, sized to the last batch — not a backlog.
|
||||
- **A family that is convention, not copies** — component-local `load` /
|
||||
`toggle` / `save` that happen to share a name — is dismissed, not
|
||||
consolidated: `classify_shapes(..., status="exempt",
|
||||
reason_code="convention-plumbing", reason=…)` (or `classify_shapes_by_rule`
|
||||
for a whole family) removes it from the queue. Dismissal is a judgment and
|
||||
it is recorded; silence is not.
|
||||
- **CSS is watched by name, never by body** (note 2917). Classes serving
|
||||
different purposes share declarations because the style system makes them
|
||||
alike — `.text-muted` and `.pin-badge-auto` carrying the same `color:` are
|
||||
two meanings, not two copies — so a CSS family is the *same class defined
|
||||
in ≥2 files* (a recipe living in several places), and identical bodies
|
||||
under different names are never a family. Derive a CSS family by moving
|
||||
the recipe to the shared sheet and recording it; a class name reused for
|
||||
genuinely different things is dismissed with `reason_code="scoped-css"`.
|
||||
The datum that decides between the two is **what renders it**: every css
|
||||
row carries `used_by` (the files whose markup names the class — the CSS
|
||||
consumer map, milestone 302), a derive group carries the family's
|
||||
`consumers`, and the write-path line says "used by N template(s)". Many
|
||||
templates, one recipe → derive; one template each, different purposes →
|
||||
dismiss. `list_shapes(flag="unused-css")` is the map's negative space —
|
||||
css rules no template names, a deletion candidate to look at, never
|
||||
auto-deleted (a class built at runtime is invisible to the map).
|
||||
|
||||
After the one-time pay-down the derive queue reads empty; anything in it
|
||||
afterwards is drift of the moment, and the hint already said so at the write.
|
||||
|
||||
## The divergence readout — button B where button A is canon
|
||||
|
||||
Three questions the ledger answers mechanically (#2793):
|
||||
|
||||
+41
-7
@@ -172,15 +172,24 @@ def check_shellcheck() -> None:
|
||||
# --- the fail-open contract ------------------------------------------------
|
||||
|
||||
# Every hook promises never to break the operator's session: unconfigured or
|
||||
# unreachable, it exits 0. Three of them additionally promise SILENCE, because
|
||||
# they are pure enrichment. scribe_session_context.sh is the exception by
|
||||
# design — it always emits a static behavioural floor that needs no credentials
|
||||
# and no network, so "silent" would be the wrong assertion for it.
|
||||
# unreachable, it exits 0. Unconfigured, the enrichment hooks are SILENT — no
|
||||
# call was owed. scribe_session_context.sh is the exception by design — it
|
||||
# always emits a static behavioural floor that needs no credentials and no
|
||||
# network, so "silent" would be the wrong assertion for it.
|
||||
#
|
||||
# UNREACHABLE is different for the two write-path hooks since #2932: a call
|
||||
# that was owed and did not come back is SAID, once per outage ("> Scribe did
|
||||
# not answer …"), so a session can tell "checked, nothing there" from "never
|
||||
# checked". That line — or silence, when the once-per-outage marker in
|
||||
# ${TMPDIR:-/tmp}/scribe-priorart/ was set by a run in the last ten minutes —
|
||||
# is the only output allowed with no working instance; anything else is a hook
|
||||
# speaking on data it cannot have.
|
||||
#
|
||||
# This is the contract that made #2198 invisible for weeks, so it is worth
|
||||
# pinning: the bug and the healthy no-results case look identical from outside.
|
||||
# Pinning it does NOT make the failure visible; it makes sure the fail-open
|
||||
# behaviour is deliberate rather than accidental.
|
||||
# pinning: the bug and the healthy no-results case looked identical from
|
||||
# outside. #2932 is what finally makes the failure visible at the write; this
|
||||
# check makes sure the fail-open behaviour stays deliberate rather than
|
||||
# accidental.
|
||||
# A symbol that exists nowhere, ASSEMBLED rather than written literally.
|
||||
# The prior-art hook's local arm (#2280) fires with no credentials, so the
|
||||
# silence assertion below needs a name the repo genuinely lacks. Two traps,
|
||||
@@ -203,10 +212,24 @@ SMOKE_EVENTS: dict[str, str] = {
|
||||
),
|
||||
"scribe_sync_processes.sh": json.dumps({"source": "startup"}),
|
||||
"scribe_session_context.sh": json.dumps({"source": "startup"}),
|
||||
# The after-write hook (#2901) diffs the working tree; on CI's clean
|
||||
# checkout there is nothing to report, so silence is the right assertion.
|
||||
# (On a dirty local tree with a definition just written it may speak —
|
||||
# that is the hook working, not a failure of the contract.)
|
||||
"scribe_after_write.sh": json.dumps(
|
||||
{"session_id": "smoke", "cwd": ".", "tool_name": "Bash",
|
||||
"tool_input": {"command": "true"}, "tool_response": {}}
|
||||
),
|
||||
# The shared library is sourced, never run; executed bare it defines
|
||||
# functions and exits — silent by construction.
|
||||
"scribe_defs.sh": "",
|
||||
}
|
||||
|
||||
# The one hook that legitimately produces output with no credentials.
|
||||
STATIC_FLOOR = "scribe_session_context.sh"
|
||||
# The hooks that say so when a configured instance does not answer (#2932).
|
||||
OUTAGE_SPEAKERS = {"scribe_prior_art.sh", "scribe_after_write.sh"}
|
||||
OUTAGE_LINE = "> Scribe did not answer the prior-art check"
|
||||
|
||||
|
||||
def _run_hook(script: Path, event: str, env_extra: dict[str, str]) -> subprocess.CompletedProcess:
|
||||
@@ -258,6 +281,17 @@ def check_fail_open() -> None:
|
||||
f"behavioural floor must survive having no credentials")
|
||||
else:
|
||||
ok(f"{rel} [{label}]: exit 0, static floor present")
|
||||
elif out and label == "unreachable" and script.name in OUTAGE_SPEAKERS:
|
||||
# The only thing allowed here is the outage line itself.
|
||||
try:
|
||||
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
|
||||
except (ValueError, KeyError, TypeError):
|
||||
ctx = ""
|
||||
if ctx.startswith(OUTAGE_LINE):
|
||||
ok(f"{rel} [{label}]: exit 0, says the instance did not answer")
|
||||
else:
|
||||
fail(f"{rel} [{label}]: emitted output with no working instance "
|
||||
f"that is not the outage line:\n {out[:200]}")
|
||||
elif out:
|
||||
fail(f"{rel} [{label}]: emitted output with no working instance:\n"
|
||||
f" {out[:200]}")
|
||||
|
||||
@@ -116,10 +116,17 @@ async def list_shapes(
|
||||
classify it: instance if it should use the canon, variant with
|
||||
the why if deliberate); "recheck": judged instances/variants
|
||||
whose body changed since judged (the judgment stands; confirm
|
||||
it again with classify_shapes, or re-judge).
|
||||
it again with classify_shapes, or re-judge); "unused-css"
|
||||
(milestone 302): live css rules no file's markup names — a
|
||||
deletion candidate to look at, never auto-deleted (the map reads
|
||||
templates only; a class built at runtime is invisible to it).
|
||||
|
||||
Returns {"shapes": [...], "total": N} — total counts every match, not
|
||||
just this page. Each row's `classified_by` says who judged: agent /
|
||||
just this page. Every css row carries `used_by` {count, paths} — the
|
||||
files whose markup names its class (milestone 302, the CSS consumer
|
||||
map: a scoped rule is used by its own template; a shared recipe by
|
||||
many; a count of 0 is "no template names it"). Each row's
|
||||
`classified_by` says who judged: agent /
|
||||
audit / import are judgments; `mechanical` is the canonical stamp the
|
||||
sync applies; `hook` is write-path EVIDENCE (#2791) — the session pulled
|
||||
a snippet and then wrote code referencing/resembling it, so the shape
|
||||
@@ -145,10 +152,12 @@ async def list_shapes(
|
||||
include_vanished=include_vanished, limit=limit, offset=offset,
|
||||
proposal=proposal, flag=flag, uses=uses,
|
||||
)
|
||||
return {
|
||||
"shapes": [r.to_compact() if compact else r.to_dict() for r in rows],
|
||||
"total": total,
|
||||
}
|
||||
shapes = [r.to_compact() if compact else r.to_dict() for r in rows]
|
||||
used_by = await shape_ledger_svc.used_by_map(rows)
|
||||
for row, out in zip(rows, shapes):
|
||||
if row.id in used_by:
|
||||
out["used_by"] = used_by[row.id]
|
||||
return {"shapes": shapes, "total": total}
|
||||
|
||||
|
||||
async def classify_shapes_by_rule(
|
||||
@@ -295,7 +304,12 @@ async def refresh_pattern_coverage(project_id: int) -> dict:
|
||||
Returns the accounting payload — total, accounted, counts by status,
|
||||
unclassified, repos, largest_gaps, `proposed` (canon proposals awaiting
|
||||
confirmation), `derive_groups` (the biggest repeats-with-no-canon
|
||||
families), `proposer` (what this refresh examined) — plus
|
||||
families, each css one with `consumers` — the files whose markup
|
||||
render it, milestone 302), `unused_css` (css rules no template names;
|
||||
None where the map has no evidence of templates), `derive_new` (copies
|
||||
that joined a family since the previous
|
||||
refresh — the drift to act on now: derive the canon, don't queue an
|
||||
audit), `proposer` (what this refresh examined) — plus
|
||||
`pattern_coverage`, the same one-line summary enter_project carries.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
|
||||
@@ -44,6 +44,6 @@ from scribe.models.rulebook import ( # noqa: E402, F401
|
||||
)
|
||||
from scribe.models.repo_binding import RepoBinding # noqa: E402, F401
|
||||
from scribe.models.forge_connection import ForgeConnection # noqa: E402, F401
|
||||
from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse # noqa: E402, F401
|
||||
from scribe.models.code_shape import CodeShape, CodeShapeConsumer, CodeShapeEvent, CodeShapeUse # noqa: E402, F401
|
||||
from scribe.models.system import System, RecordSystem # noqa: E402, F401
|
||||
from scribe.models.design_system import DesignSystem, DesignToken # noqa: E402, F401
|
||||
|
||||
@@ -265,6 +265,52 @@ class CodeShapeUse(Base):
|
||||
}
|
||||
|
||||
|
||||
# How a consumer edge was established (milestone 302). `template` is the
|
||||
# sync's mechanical read of a file's markup (class= / :class= / className=);
|
||||
# the vocabulary is a list so a later basis (a stylesheet `@apply`, a script's
|
||||
# classList) has a name without a schema change.
|
||||
CONSUMER_BASES = ("template",)
|
||||
|
||||
|
||||
class CodeShapeConsumer(Base):
|
||||
"""One consumer edge: CSS shape → the file whose markup names its class
|
||||
(milestone 302; note 2917 — CSS is watched by name, by recipe, by token
|
||||
and by WHAT USES IT). The analogue of CodeShapeUse for styling: `uses`
|
||||
says what a shape calls, this says who renders a class. Rows, not prose,
|
||||
so "is this recipe shared or scoped?" is a count, not a guess.
|
||||
|
||||
Mechanical and fully recomputable: every coverage sync rebuilds a repo's
|
||||
edges from its archive, so the table is not backed up (see
|
||||
services/backup._NOT_INCLUDED). Cascades with the shape.
|
||||
"""
|
||||
|
||||
__tablename__ = "code_shape_consumers"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("shape_id", "path", name="uq_code_shape_consumers_shape_path"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
shape_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("code_shapes.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
count: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
basis: Mapped[str] = mapped_column(Text, nullable=False, default="template")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"shape_id": self.shape_id,
|
||||
"path": self.path,
|
||||
"count": self.count,
|
||||
"basis": self.basis,
|
||||
"created_at": iso(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
# What a shape's history records (#2793). Not "appeared" — first_seen and
|
||||
# created_at already say that on the row; history is for what CHANGED:
|
||||
SHAPE_EVENTS = ("classified", "vanished", "reappeared", "drifted")
|
||||
|
||||
@@ -129,6 +129,10 @@ async def write_path_prior_art():
|
||||
surfaced. A separate channel on purpose: a reuse
|
||||
hint shown early must not suppress the record-sync
|
||||
nudge when the recorded file is edited later.
|
||||
exclude_derive (opt) — comma-separated derive keys (a derive group id
|
||||
or `canon:<snippet_id>`) already named this
|
||||
session by the ledger arm (#2900); its own
|
||||
channel, like the two above.
|
||||
shapes (opt) — comma-separated `kind:name` definitions the hook
|
||||
found in (or enclosing) the payload, kind being
|
||||
css|sym. The shape ledger's write-path feed
|
||||
@@ -144,6 +148,9 @@ async def write_path_prior_art():
|
||||
project_id, repo, _unbound = await _project_scope()
|
||||
exclude_ids = _int_list(request.args.get("exclude_ids"))
|
||||
exclude_sync_ids = _int_list(request.args.get("exclude_sync_ids"))
|
||||
exclude_derive = [
|
||||
p.strip() for p in (request.args.get("exclude_derive") or "").split(",") if p.strip()
|
||||
]
|
||||
shapes = _parse_shapes(request.args.get("shapes") or "")
|
||||
api_key = getattr(g, "api_key", None)
|
||||
may_stamp = api_key is None or getattr(api_key, "scope", "") == "write"
|
||||
@@ -153,6 +160,7 @@ async def write_path_prior_art():
|
||||
exclude_ids=exclude_ids, exclude_sync_ids=exclude_sync_ids,
|
||||
stamp_shapes=shapes if may_stamp else None,
|
||||
repo_key=repo_bindings_svc.normalize_repo_key(repo) if repo else "",
|
||||
exclude_derive=exclude_derive,
|
||||
)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@@ -92,6 +92,10 @@ _NOT_INCLUDED = [
|
||||
# deliberately not exported either, so restored projects fall back to
|
||||
# keyring-by-host resolution — the documented unpinned behavior (#2778).
|
||||
"forge_connections",
|
||||
# Derived, like note_embeddings: the CSS consumer map (milestone 302) is
|
||||
# rebuilt from the repo archive by every coverage sync, and carries no
|
||||
# judgment — the first refresh after a restore recreates it exactly.
|
||||
"code_shape_consumers",
|
||||
]
|
||||
|
||||
|
||||
|
||||
+219
-29
@@ -123,6 +123,12 @@ def _definition_on(raw: str) -> tuple[str, str] | None:
|
||||
name = m.group(1)
|
||||
if name.startswith("__") and name.endswith("__"):
|
||||
return None
|
||||
# `type` announces a definition only when something is declared after
|
||||
# the name (`type Foo = …`, `type Foo struct {`); an import specifier
|
||||
# (`import { type Foo, bar }`) is the same two words and defines
|
||||
# nothing — it showed up as a two-file "identical body" family (#2904).
|
||||
if line.startswith("type") and not re.search(r"[={]", line[m.end():]):
|
||||
return None
|
||||
return ("sym", name)
|
||||
if m := _ARROW_RE.match(line):
|
||||
return ("sym", m.group(1))
|
||||
@@ -156,6 +162,12 @@ def _block_sha(lines: list[str]) -> str:
|
||||
return hashlib.sha1("\n".join(kept).encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _declaration_count(lines: list[str]) -> int:
|
||||
"""How many `prop: value` declarations a CSS block body carries."""
|
||||
body = " ".join(lines)
|
||||
return sum(1 for part in body.replace("}", "").split(";") if ":" in part)
|
||||
|
||||
|
||||
def extract_definitions(text: str) -> list[Definition]:
|
||||
"""Every definition this text makes, with signature + fingerprint.
|
||||
|
||||
@@ -186,11 +198,13 @@ def extract_definitions(text: str) -> list[Definition]:
|
||||
break
|
||||
block = lines[i:end]
|
||||
# A CSS rule's fingerprint is its DECLARATIONS, not its selector
|
||||
# (#2872): the row's identity already carries the selector, and the
|
||||
# question the fingerprint answers for derive grouping is "is this the
|
||||
# same rule under another name?" — .closed-msg / .error-block /
|
||||
# .success-msg with identical bodies are one dup group, not three
|
||||
# lonely rows. Sym blocks keep their signature line in the hash.
|
||||
# (#2872): the row's identity already carries the selector. Since
|
||||
# note 2917 the derive grouping no longer reads CSS bodies at all (a
|
||||
# class is grouped by name only), so for CSS the fingerprint is the
|
||||
# recheck identity — "did this rule's body change since it was
|
||||
# judged?" — and nothing more. The shape of the hash is kept as-is on
|
||||
# purpose: changing it would flip every judged CSS row to recheck on
|
||||
# the next sync. Sym blocks keep their signature line in the hash.
|
||||
if kind == "css":
|
||||
# One-line rules (`.x { color: red; }`) carry their declarations on
|
||||
# the selector line itself; a block that is only the selector plus
|
||||
@@ -202,6 +216,14 @@ def extract_definitions(text: str) -> list[Definition]:
|
||||
hashed = head + block[1:]
|
||||
if not any(x.strip() for x in hashed):
|
||||
hashed = block
|
||||
# A SINGLE declaration is not a shape (#2903): `color: var(--fs-
|
||||
# text-tertiary)` under .text-muted, .task-mark and .pin-badge-auto
|
||||
# is three meanings sharing one line, not three copies of one
|
||||
# rule. Keep the selector in the hash for one-liners; two
|
||||
# declarations and up stay selector-agnostic. (Moot for grouping
|
||||
# since note 2917, kept for fingerprint stability — see above.)
|
||||
elif _declaration_count(hashed) < 2:
|
||||
hashed = block
|
||||
else:
|
||||
hashed = block
|
||||
out.append(Definition(
|
||||
@@ -247,6 +269,91 @@ def scoped_definitions(path: str, text: str, defs: list[Definition]) -> set[tupl
|
||||
return out
|
||||
|
||||
|
||||
# --- template class references: the CSS consumer map (milestone 302) ---------
|
||||
|
||||
# Files whose MARKUP can consume a class. Styling consumers are templates —
|
||||
# `querySelector('.x')` / classList in scripts are deliberately not read in
|
||||
# v1 (note 2917: watch CSS by name, by recipe, by token and by what uses it;
|
||||
# "what uses it" is the template).
|
||||
_TEMPLATE_SUFFIXES = (
|
||||
".vue", ".html", ".htm", ".jsx", ".tsx", ".js", ".ts", ".svelte", ".astro",
|
||||
)
|
||||
_CLASS_TOKEN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$")
|
||||
# Static: class="a b" / class='a b' / className="a b". The lookbehind keeps
|
||||
# `:class=`, `v-bind:class=`, `data-class=` and `headerClass=` out of the
|
||||
# static form (the Vue/React dynamic forms are read below; the others are
|
||||
# not class attributes).
|
||||
_STATIC_CLASS_RE = re.compile(
|
||||
r"""(?<![:\w.-])(?:class|className)\s*=\s*(?:"([^"]*)"|'([^']*)')"""
|
||||
)
|
||||
# Dynamic: Vue `:class="…"` / `v-bind:class="…"`, React `className={…}` (one
|
||||
# level of nested braces — an object literal inside the expression).
|
||||
_DYNAMIC_CLASS_RE = re.compile(
|
||||
r""":class\s*=\s*(?:"([^"]*)"|'([^']*)')"""
|
||||
r"""|(?<![:\w.-])className\s*=\s*\{((?:[^{}]|\{[^{}]*\})*)\}"""
|
||||
)
|
||||
# Svelte's directive form: class:active={cond}.
|
||||
_SVELTE_CLASS_RE = re.compile(r"(?<![:\w.-])class:([A-Za-z_][A-Za-z0-9_-]*)\s*=")
|
||||
# Inside a dynamic expression: string literals (ternary arms, array items,
|
||||
# quoted object keys) and the bare keys of object literals.
|
||||
_STR_LIT_RE = re.compile(r"""'([^'\\]*)'|"([^"\\]*)"|`([^`]*)`""")
|
||||
_OBJ_SPAN_RE = re.compile(r"\{([^{}]*)\}")
|
||||
_OBJ_KEY_RE = re.compile(r"(?:^|[{,\s])([A-Za-z_][A-Za-z0-9_-]*)\s*:(?!:)")
|
||||
_TEMPLATE_HOLE_RE = re.compile(r"\$\{[^}]*\}")
|
||||
# A server-side / mustache interpolation inside a static value (`{{ cls }}`,
|
||||
# `{% if %}`): unknowable at read time, contributes no token.
|
||||
_MUSTACHE_RE = re.compile(r"\{[{%][^}]*[}%]\}")
|
||||
|
||||
|
||||
def _class_tokens(value: str) -> list[str]:
|
||||
"""The class tokens of a static attribute value: whitespace-split, only
|
||||
well-formed names (an interpolation like `{{ cls }}` contributes none)."""
|
||||
return [t for t in _MUSTACHE_RE.sub(" ", value).split() if _CLASS_TOKEN_RE.match(t)]
|
||||
|
||||
|
||||
def _dynamic_class_tokens(expr: str) -> list[str]:
|
||||
"""Class tokens named by a dynamic class expression: every string
|
||||
literal's tokens (a template literal's static text only — its `${…}`
|
||||
holes are unknowable) and the bare keys of object literals. Bare
|
||||
identifiers elsewhere (`cond ? clsA : clsB`) are variables, not names."""
|
||||
out: list[str] = []
|
||||
for m in _STR_LIT_RE.finditer(expr):
|
||||
literal = m.group(1) if m.group(1) is not None else (
|
||||
m.group(2) if m.group(2) is not None else m.group(3)
|
||||
)
|
||||
if m.group(3) is not None:
|
||||
literal = _TEMPLATE_HOLE_RE.sub(" ", literal)
|
||||
out.extend(_class_tokens(literal))
|
||||
for span in _OBJ_SPAN_RE.finditer(expr):
|
||||
# Quoted keys were read as literals above; bare keys here.
|
||||
body = _STR_LIT_RE.sub(" ", span.group(1))
|
||||
out.extend(k for k in _OBJ_KEY_RE.findall(body) if _CLASS_TOKEN_RE.match(k))
|
||||
return out
|
||||
|
||||
|
||||
def class_references(path: str, text: str) -> dict[str, int]:
|
||||
"""class token → how many times this file's markup names it. Empty for
|
||||
files that carry no markup (by suffix). Reads the static `class=` /
|
||||
`className=` attributes, the Vue and React dynamic forms and Svelte's
|
||||
`class:x` directive; never a CSS selector (`.x {` is a definition, read
|
||||
by extract_definitions) and never a script's `querySelector('.x')`."""
|
||||
if not (path or "").lower().endswith(_TEMPLATE_SUFFIXES):
|
||||
return {}
|
||||
counts: dict[str, int] = {}
|
||||
|
||||
def bump(tokens: list[str]) -> None:
|
||||
for t in tokens:
|
||||
counts[t] = counts.get(t, 0) + 1
|
||||
|
||||
for m in _STATIC_CLASS_RE.finditer(text):
|
||||
bump(_class_tokens(m.group(1) if m.group(1) is not None else m.group(2)))
|
||||
for m in _DYNAMIC_CLASS_RE.finditer(text):
|
||||
expr = next((g for g in m.groups() if g is not None), "")
|
||||
bump(_dynamic_class_tokens(expr))
|
||||
bump([m.group(1) for m in _SVELTE_CLASS_RE.finditer(text)])
|
||||
return counts
|
||||
|
||||
|
||||
def extract_shapes(text: str) -> list[tuple[str, str]]:
|
||||
"""Every (kind, name) this text DEFINES — kind is "css" or "sym".
|
||||
|
||||
@@ -285,14 +392,31 @@ def shapes_from_archive(blob: bytes) -> list[tuple[str, str, str]]:
|
||||
return [(d.path, d.kind, d.name) for d in definitions_from_archive(blob)]
|
||||
|
||||
|
||||
class ArchiveScan(NamedTuple):
|
||||
"""One walk of a repo tarball: what each file DEFINES (the ledger rows)
|
||||
and which class names each file's markup REFERENCES (the CSS consumer
|
||||
map, milestone 302) — read together because the bodies are in hand once."""
|
||||
|
||||
definitions: list[ArchiveShape]
|
||||
references: dict[str, dict[str, int]] # path → class token → count
|
||||
|
||||
|
||||
def definitions_from_archive(blob: bytes) -> list[ArchiveShape]:
|
||||
"""Every definition in a repo tarball, with its fingerprint and body.
|
||||
"""Every definition in a repo tarball, with its fingerprint and body —
|
||||
the definitions half of scan_archive."""
|
||||
return scan_archive(blob).definitions
|
||||
|
||||
|
||||
def scan_archive(blob: bytes) -> ArchiveScan:
|
||||
"""Every definition in a repo tarball, with its fingerprint and body,
|
||||
plus each template-bearing file's class references.
|
||||
|
||||
Forge archives wrap content in a single top-level directory (repo-ref/);
|
||||
that component is stripped so paths match recorded snippet locations,
|
||||
which are repo-relative. Non-UTF-8 files are binaries and skipped.
|
||||
"""
|
||||
shapes: list[ArchiveShape] = []
|
||||
references: dict[str, dict[str, int]] = {}
|
||||
with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tar:
|
||||
for member in tar:
|
||||
if not member.isfile() or "/" not in member.name:
|
||||
@@ -316,7 +440,10 @@ def definitions_from_archive(blob: bytes) -> list[ArchiveShape]:
|
||||
)
|
||||
for d in defs
|
||||
)
|
||||
return shapes
|
||||
refs = class_references(path, text)
|
||||
if refs:
|
||||
references[path] = refs
|
||||
return ArchiveScan(shapes, references)
|
||||
|
||||
|
||||
# --- matching shapes against recorded locations ------------------------------
|
||||
@@ -428,7 +555,8 @@ async def compute_coverage(
|
||||
# The binding's own ref when it names one (#2873: a dev-first project
|
||||
# has its ledger follow dev), else the forge's default branch.
|
||||
ref = binding.ref or await forge.default_branch(api_repo)
|
||||
definitions = definitions_from_archive(await forge.archive(api_repo, ref))
|
||||
scan = scan_archive(await forge.archive(api_repo, ref))
|
||||
definitions = scan.definitions
|
||||
# The head commit is provenance sugar on the ledger rows; failing to
|
||||
# learn it must not fail the sync — the ref names the point well
|
||||
# enough and the row timestamps carry the when.
|
||||
@@ -440,6 +568,13 @@ async def compute_coverage(
|
||||
project_id, key, definitions, seen_marker=marker
|
||||
)
|
||||
served.append((key, ref))
|
||||
# The CSS consumer map (milestone 302) rides the same archive: which
|
||||
# files' markup names each class. Mechanical and recomputable, so it
|
||||
# must not be able to fail the refresh either.
|
||||
try:
|
||||
await shape_ledger.sync_repo_consumers(project_id, key, scan.references)
|
||||
except Exception:
|
||||
logger.warning("consumer map sync failed for %s", key, exc_info=True)
|
||||
# Propose while the bodies are in hand — the one moment they exist.
|
||||
# Canonical marking below only touches rows the proposer leaves
|
||||
# alone (a canon's own location never gets a proposal), so the order
|
||||
@@ -462,15 +597,20 @@ async def compute_coverage(
|
||||
await shape_ledger.apply_derive_groups(project_id)
|
||||
except Exception:
|
||||
logger.warning("derive-first grouping failed", exc_info=True)
|
||||
# The button-B pass (#2793): shapes new since the PREVIOUS computation,
|
||||
# where a canon dominates. The previous computation's stamp is the cache;
|
||||
# a first seed has none, so it flags nothing (everything is new then).
|
||||
# "Since the previous computation" — the cache's stamp. A first seed has
|
||||
# none, so nothing is new then. Read once; two passes use it: the
|
||||
# button-B flag (#2793) and the derive-new drift count (#2899).
|
||||
since = None
|
||||
try:
|
||||
previous = await get_setting(user_id, f"{_CACHE_KEY_PREFIX}{project_id}")
|
||||
since = None
|
||||
if previous:
|
||||
stamp = (json.loads(previous) or {}).get("computed_at")
|
||||
since = datetime.fromisoformat(stamp) if stamp else None
|
||||
except Exception:
|
||||
logger.warning("previous coverage stamp unreadable", exc_info=True)
|
||||
# The button-B pass (#2793): shapes new since the PREVIOUS computation,
|
||||
# where a canon dominates.
|
||||
try:
|
||||
await shape_ledger.flag_divergence(project_id, since=since)
|
||||
except Exception:
|
||||
logger.warning("divergence pass failed", exc_info=True)
|
||||
@@ -489,8 +629,25 @@ async def compute_coverage(
|
||||
agg["accounted"] += row.status != "unclassified"
|
||||
|
||||
unclassified = counts.pop("unclassified")
|
||||
proposals = shape_ledger.proposal_summary(rows)
|
||||
# The CSS consumer map's readout (milestone 302): which files render each
|
||||
# css row — on the derive groups (a shared recipe vs a scoped one is a
|
||||
# count), and the negative space: css rules no template names. "Unused"
|
||||
# is measured only where the map has evidence of templates at all (one
|
||||
# edge somewhere); a repo of bare stylesheets is "not measured", not
|
||||
# "all unused".
|
||||
css_rows = [r for r in rows if r.kind == "css"]
|
||||
consumer_paths: dict[int, list[str]] = {}
|
||||
unused_css = None
|
||||
try:
|
||||
edges = await shape_ledger.consumers_of([r.id for r in css_rows])
|
||||
consumer_paths = {sid: [e.path for e in es] for sid, es in edges.items()}
|
||||
if consumer_paths:
|
||||
unused_css = sum(1 for r in css_rows if r.id not in consumer_paths)
|
||||
except Exception:
|
||||
logger.warning("consumer map read failed", exc_info=True)
|
||||
proposals = shape_ledger.proposal_summary(rows, consumer_paths=consumer_paths)
|
||||
divergence = shape_ledger.divergence_summary(rows)
|
||||
derive_new = shape_ledger.derive_new_summary(rows, since=since)
|
||||
return {
|
||||
"total": len(rows),
|
||||
"accounted": len(rows) - unclassified,
|
||||
@@ -501,6 +658,13 @@ async def compute_coverage(
|
||||
"proposed": proposals["proposed"],
|
||||
"derive_groups": proposals["derive_groups"],
|
||||
"top_canon": proposals.get("top_canon"),
|
||||
# Drift since the previous refresh (#2899): copies that joined a
|
||||
# duplicate family — what the arrival line names so drift is noticed
|
||||
# on entering, not found by an audit.
|
||||
"derive_new": derive_new,
|
||||
# The consumer map's negative space (milestone 302): live css rules
|
||||
# no template names — None when the map has no evidence of templates.
|
||||
"unused_css": unused_css,
|
||||
"proposer": proposer_stats,
|
||||
# The divergence readout (#2793): button B where button A is canon,
|
||||
# and judged shapes whose bodies moved since they were judged.
|
||||
@@ -648,29 +812,55 @@ def coverage_line(coverage: dict) -> str:
|
||||
line += f" — {breakdown}"
|
||||
line += f" (estimate{', computed ' + day if day else ''})"
|
||||
unclassified = coverage.get("unclassified", 0)
|
||||
# The standing work, built whatever the todo count (#2899). Since the
|
||||
# scoped bucket (#2869) a ledger can read 100% accounted and still carry
|
||||
# derive groups, proposals and divergence; gating this block on
|
||||
# `unclassified > 0` is how 439 derive rows went unmentioned.
|
||||
standing = []
|
||||
if coverage.get("proposed"):
|
||||
standing.append(f"{coverage['proposed']} proposed")
|
||||
n_groups = len(coverage.get("derive_groups") or [])
|
||||
if n_groups:
|
||||
standing.append(f"{n_groups} derive group{'s' if n_groups != 1 else ''}")
|
||||
# Drift since the previous refresh: copies that joined a family, the
|
||||
# first one named — the sentence the arrival moment exists to say.
|
||||
new = coverage.get("derive_new") or {}
|
||||
if new.get("count"):
|
||||
n = new["count"]
|
||||
first_new = (new.get("examples") or [{}])[0]
|
||||
where = (
|
||||
f": {first_new['label']} in {first_new['path']}"
|
||||
if first_new.get("label") and first_new.get("path") else ""
|
||||
)
|
||||
standing.append(f"+{n} new cop{'y' if n == 1 else 'ies'} since last refresh{where}")
|
||||
if coverage.get("divergent"):
|
||||
standing.append(f"{coverage['divergent']} DIVERGENT")
|
||||
# The next action, on the line (#2874): the canon with the biggest
|
||||
# queue to confirm, and the widest body-identical copy to consolidate.
|
||||
top = coverage.get("top_canon") or {}
|
||||
if top.get("snippet_id"):
|
||||
standing.append(f"top canon #{top['snippet_id']} ×{top.get('count', 0)}")
|
||||
first = (coverage.get("derive_groups") or [{}])[0]
|
||||
if first.get("label") and first.get("files"):
|
||||
top_copy = f"top copy {first['label']} ×{first['files']} files"
|
||||
# A css family says what renders it (milestone 302): the count that
|
||||
# tells a shared recipe from a scoped convention.
|
||||
if "consumers" in first:
|
||||
n_t = (first.get("consumers") or {}).get("count", 0)
|
||||
top_copy += f" · used by {n_t} template{'s' if n_t != 1 else ''}"
|
||||
standing.append(top_copy)
|
||||
if coverage.get("unused_css"):
|
||||
n_u = coverage["unused_css"]
|
||||
standing.append(f"{n_u} unused class{'es' if n_u != 1 else ''}")
|
||||
if unclassified:
|
||||
line += f"; {unclassified} unclassified"
|
||||
standing = []
|
||||
if coverage.get("proposed"):
|
||||
standing.append(f"{coverage['proposed']} proposed")
|
||||
n_groups = len(coverage.get("derive_groups") or [])
|
||||
if n_groups:
|
||||
standing.append(f"{n_groups} derive group{'s' if n_groups != 1 else ''}")
|
||||
if coverage.get("divergent"):
|
||||
standing.append(f"{coverage['divergent']} DIVERGENT")
|
||||
# The next action, on the line (#2874): the canon with the biggest
|
||||
# queue to confirm, and the widest body-identical copy to consolidate.
|
||||
top = coverage.get("top_canon") or {}
|
||||
if top.get("snippet_id"):
|
||||
standing.append(f"top canon #{top['snippet_id']} ×{top.get('count', 0)}")
|
||||
first = (coverage.get("derive_groups") or [{}])[0]
|
||||
if first.get("label") and first.get("files"):
|
||||
standing.append(f"top copy {first['label']} ×{first['files']} files")
|
||||
if standing:
|
||||
line += f" ({', '.join(standing)})"
|
||||
gaps = [g["dir"] for g in coverage.get("largest_gaps") or []]
|
||||
if gaps:
|
||||
line += ", largest: " + ", ".join(gaps)
|
||||
elif standing:
|
||||
line += f"; standing: {', '.join(standing)}"
|
||||
if coverage.get("recheck"):
|
||||
line += f"; {coverage['recheck']} judged shape{'s' if coverage['recheck'] != 1 else ''} changed since judged — recheck"
|
||||
return line
|
||||
|
||||
@@ -706,6 +706,7 @@ async def build_write_path_hint(
|
||||
exclude_sync_ids: list[int] | None = None,
|
||||
stamp_shapes: list[tuple[str, str]] | None = None,
|
||||
repo_key: str = "",
|
||||
exclude_derive: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
|
||||
|
||||
@@ -765,7 +766,7 @@ async def build_write_path_hint(
|
||||
"""
|
||||
cfg = await get_writepath_config(user_id)
|
||||
empty = {"context": "", "note_ids": [], "sync_note_ids": [], "config": cfg,
|
||||
"stamped": [], "divergence": []}
|
||||
"stamped": [], "divergence": [], "derive": [], "derive_keys": []}
|
||||
path = (path or "").strip()
|
||||
if not cfg["enabled"] or not path:
|
||||
return empty
|
||||
@@ -935,7 +936,20 @@ async def build_write_path_hint(
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("write-time divergence check failed", exc_info=True)
|
||||
if not synced and not menu and not stamped and not divergence:
|
||||
# The in-band DERIVE check (#2900): the ledger's own knowledge of the
|
||||
# names being written — a duplicate family with no canon, or a canon
|
||||
# recorded elsewhere. This is the arm the by-name local grep could not
|
||||
# be: it knows whether the other copies are canon or stray. Keyed per
|
||||
# session (`exclude_derive`) so a family is named once, not per edit.
|
||||
derive: list[dict] = []
|
||||
if stamp_shapes and project_id:
|
||||
try:
|
||||
found = await shape_ledger_svc.write_time_derive(project_id, path, stamp_shapes)
|
||||
skip = set(exclude_derive or [])
|
||||
derive = [d for d in found if d.get("key") not in skip]
|
||||
except Exception:
|
||||
logger.warning("write-time derive check failed", exc_info=True)
|
||||
if not synced and not menu and not stamped and not divergence and not derive:
|
||||
return empty
|
||||
|
||||
owners = await owner_names_for({
|
||||
@@ -1003,6 +1017,8 @@ async def build_write_path_hint(
|
||||
lines.append(_stamp_line(path, stamped))
|
||||
if divergence:
|
||||
lines.append(_divergence_line(path, divergence))
|
||||
if derive:
|
||||
lines.append(_derive_line(path, derive))
|
||||
|
||||
# Split by arm, which is the whole reason this table exists. The place arm
|
||||
# carries no score and so has no home in retrieval_logs; before #2085 a
|
||||
@@ -1027,9 +1043,62 @@ async def build_write_path_hint(
|
||||
"config": cfg,
|
||||
"stamped": stamped,
|
||||
"divergence": divergence,
|
||||
"derive": derive,
|
||||
"derive_keys": [d["key"] for d in derive],
|
||||
}
|
||||
|
||||
|
||||
def _derive_line(path: str, derive: list[dict]) -> str:
|
||||
"""The ledger's word on the names being written (#2900): a duplicate
|
||||
family to derive, or a canon to reuse — said at the write."""
|
||||
parts = []
|
||||
for d in derive:
|
||||
if d.get("canon"):
|
||||
c = d["canon"]
|
||||
parts.append(
|
||||
f"`{c['label']}` is canon — snippet #{c['snippet_id']} at `{c['path']}`; "
|
||||
"pull it and reuse, don't redefine"
|
||||
)
|
||||
continue
|
||||
f = d["family"]
|
||||
files = ", ".join(f"`{x}`" for x in f.get("files") or [])
|
||||
more = f.get("file_count", 0) - len(f.get("files") or [])
|
||||
if more > 0:
|
||||
files += f" +{more} more"
|
||||
n = f.get("file_count", 0)
|
||||
if f.get("identical"):
|
||||
what = f"is a duplicate family with no canon — identical body in {n} other file(s)"
|
||||
else:
|
||||
# A name family: the same definition name living in several
|
||||
# files. CSS is only ever grouped this way (note 2917) — a class
|
||||
# is a recipe, and the recipe is what gets derived or dismissed.
|
||||
what = f"is a repeated name with no canon — defined in {n} other file(s)"
|
||||
# What renders a css family (milestone 302): the consumer count is
|
||||
# the datum that separates a shared recipe from a scoped convention.
|
||||
cons = f.get("consumers")
|
||||
if cons is not None:
|
||||
n_t = cons.get("count", 0)
|
||||
used = f"; used by {n_t} template{'s' if n_t != 1 else ''}"
|
||||
if cons.get("paths"):
|
||||
used += ": " + ", ".join(f"`{x}`" for x in cons["paths"])
|
||||
extra = n_t - len(cons["paths"])
|
||||
if extra > 0:
|
||||
used += f" +{extra} more"
|
||||
files += used
|
||||
# The dismissal reason the family most likely earns: a class name
|
||||
# reused for different purposes is scoped styling; a code name reused
|
||||
# across modules is convention plumbing.
|
||||
dismiss = "scoped-css" if d.get("kind") == "css" else "convention-plumbing"
|
||||
parts.append(
|
||||
f"`{f['label']}` {what}: {files}; derive it now: "
|
||||
"record the canon (create_snippet) and make the copies instances "
|
||||
"(classify_shapes) — or, if these are convention not copies, "
|
||||
f"`classify_shapes(..., status=\"exempt\", reason_code=\"{dismiss}\")` "
|
||||
"dismisses the family — rather than adding another copy"
|
||||
)
|
||||
return f"> Shape ledger at `{path}`: " + "; ".join(parts) + "."
|
||||
|
||||
|
||||
def _divergence_line(path: str, divergence: list[dict]) -> str:
|
||||
"""Button B where button A is canon — named at the write (#2793)."""
|
||||
parts = [
|
||||
|
||||
@@ -30,7 +30,9 @@ from typing import Iterable, NamedTuple
|
||||
from sqlalchemy import select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.code_shape import REASON_CODES, CodeShape, CodeShapeEvent, CodeShapeUse
|
||||
from scribe.models.code_shape import (
|
||||
REASON_CODES, CodeShape, CodeShapeConsumer, CodeShapeEvent, CodeShapeUse,
|
||||
)
|
||||
from scribe.models.base import iso
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -262,6 +264,118 @@ async def uses_of(shape_ids) -> dict[int, list[CodeShapeUse]]:
|
||||
return out
|
||||
|
||||
|
||||
# --- the CSS consumer map (milestone 302) ------------------------------------
|
||||
|
||||
|
||||
def resolve_consumers(
|
||||
css_rows: Iterable[tuple[int, str, str]],
|
||||
references: dict[str, dict[str, int]],
|
||||
) -> dict[tuple[int, str], int]:
|
||||
"""{(shape_id, consumer_path): count} — which CSS rows each file's markup
|
||||
consumes. ``css_rows`` are (id, path, symbol) of the repo's live css rows;
|
||||
``references`` is scan_archive's path → class token → count.
|
||||
|
||||
Resolution (note 2917): a class named in file F resolves to F's OWN row
|
||||
of that name when F defines it (a scoped rule is consumed by its own
|
||||
template); otherwise to every other file's row of that name — a shared
|
||||
sheet, or, when several files define it, all of them: the map says
|
||||
"ambiguous" by fanning out rather than guessing one."""
|
||||
by_symbol: dict[str, list[tuple[int, str]]] = {}
|
||||
for sid, path, symbol in css_rows:
|
||||
by_symbol.setdefault(symbol, []).append((sid, path))
|
||||
out: dict[tuple[int, str], int] = {}
|
||||
for consumer, tokens in references.items():
|
||||
for token, count in tokens.items():
|
||||
rows = by_symbol.get(token)
|
||||
if not rows:
|
||||
continue
|
||||
own = [sid for sid, path in rows if path == consumer]
|
||||
targets = own or [sid for sid, _path in rows]
|
||||
for sid in targets:
|
||||
out[(sid, consumer)] = out.get((sid, consumer), 0) + int(count)
|
||||
return out
|
||||
|
||||
|
||||
async def sync_repo_consumers(
|
||||
project_id: int, repo_key: str, references: dict[str, dict[str, int]]
|
||||
) -> int:
|
||||
"""Rebuild one repo's consumer edges from its archive's class references:
|
||||
insert the new, refresh changed counts, delete what the tree no longer
|
||||
says (a template rewritten, a class renamed, a file gone). Edges hang on
|
||||
live rows only; a vanished row's edges go with this pass. Returns how
|
||||
many edges stand afterwards."""
|
||||
async with async_session() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(CodeShape.id, CodeShape.path, CodeShape.symbol, CodeShape.vanished_at).where(
|
||||
CodeShape.project_id == project_id,
|
||||
CodeShape.repo_key == repo_key,
|
||||
CodeShape.kind == "css",
|
||||
)
|
||||
)
|
||||
).all()
|
||||
live = [(r[0], r[1], r[2]) for r in rows if r[3] is None]
|
||||
all_ids = [r[0] for r in rows]
|
||||
wanted = resolve_consumers(live, references)
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(CodeShapeConsumer).where(CodeShapeConsumer.shape_id.in_(all_ids))
|
||||
)
|
||||
).scalars().all() if all_ids else []
|
||||
have = {(e.shape_id, e.path): e for e in existing}
|
||||
for key, edge in have.items():
|
||||
if key not in wanted:
|
||||
await session.delete(edge)
|
||||
elif edge.count != wanted[key]:
|
||||
edge.count = wanted[key]
|
||||
for (sid, path), count in wanted.items():
|
||||
if (sid, path) not in have:
|
||||
session.add(CodeShapeConsumer(shape_id=sid, path=path, count=count, basis="template"))
|
||||
await session.commit()
|
||||
return len(wanted)
|
||||
|
||||
|
||||
async def consumers_of(shape_ids) -> dict[int, list[CodeShapeConsumer]]:
|
||||
"""{shape_id: [edges]} for a set of rows — the read side of the map,
|
||||
ordered by path so a readout is stable."""
|
||||
ids = [int(x) for x in shape_ids if x]
|
||||
if not ids:
|
||||
return {}
|
||||
async with async_session() as session:
|
||||
edges = (
|
||||
await session.execute(
|
||||
select(CodeShapeConsumer).where(CodeShapeConsumer.shape_id.in_(ids))
|
||||
.order_by(CodeShapeConsumer.shape_id, CodeShapeConsumer.path)
|
||||
)
|
||||
).scalars().all()
|
||||
out: dict[int, list[CodeShapeConsumer]] = {}
|
||||
for e in edges:
|
||||
out.setdefault(e.shape_id, []).append(e)
|
||||
return out
|
||||
|
||||
|
||||
# How many consumer files a readout names before "+N more".
|
||||
_CONSUMERS_SHOWN = 4
|
||||
|
||||
|
||||
def consumer_summary(paths: Iterable[str]) -> dict:
|
||||
"""{"count", "paths"} — distinct consumer files, sorted, the first few
|
||||
named. The one shape every surface uses for "used by N template(s)"."""
|
||||
files = sorted(set(paths))
|
||||
return {"count": len(files), "paths": files[:_CONSUMERS_SHOWN]}
|
||||
|
||||
|
||||
async def used_by_map(rows: Iterable[CodeShape]) -> dict[int, dict]:
|
||||
"""{shape_id: consumer_summary} for every css row given — a row with no
|
||||
consumer gets {"count": 0, "paths": []}: "no template names it" is a
|
||||
finding, not an absence."""
|
||||
css = [r for r in rows if r.kind == "css"]
|
||||
if not css:
|
||||
return {}
|
||||
edges = await consumers_of([r.id for r in css])
|
||||
return {r.id: consumer_summary(e.path for e in edges.get(r.id, [])) for r in css}
|
||||
|
||||
|
||||
async def mark_canonicals(
|
||||
project_id: int, recorded: list[tuple[int, str, str]]
|
||||
) -> None:
|
||||
@@ -574,7 +688,8 @@ async def list_project_shapes(
|
||||
suggestion), "derive" (a repeats-with-no-canon group), or one basis
|
||||
name (symbol/reference/text/signature/semantic). ``flag`` narrows to
|
||||
the readout's asks (#2793): "divergence" (new where a canon dominates,
|
||||
`diverges_from` names it) or "recheck" (a judged shape whose body moved).
|
||||
`diverges_from` names it), "recheck" (a judged shape whose body moved),
|
||||
or "unused-css" (milestone 302: a css rule no template names).
|
||||
"""
|
||||
from sqlalchemy import func, or_
|
||||
|
||||
@@ -609,6 +724,13 @@ async def list_project_shapes(
|
||||
conds.append(CodeShape.diverges_from.isnot(None))
|
||||
elif flag == "recheck":
|
||||
conds.append(CodeShape.recheck_at.isnot(None))
|
||||
elif flag == "unused-css":
|
||||
# The consumer map's negative space (milestone 302): a live css rule
|
||||
# no file's markup names. A candidate for deletion, surfaced — never
|
||||
# deleted — because the map reads templates only (a class built at
|
||||
# runtime, or used from a script, is invisible to it).
|
||||
conds.append(CodeShape.kind == "css")
|
||||
conds.append(~CodeShape.id.in_(select(CodeShapeConsumer.shape_id)))
|
||||
if uses:
|
||||
# Consumers of a canon (#2870): rows with a uses edge to it, whatever
|
||||
# shape they themselves are.
|
||||
@@ -923,6 +1045,13 @@ async def stamp_write_path_instances(
|
||||
# recur by convention, not by duplication).
|
||||
_DERIVE_MIN_DUP = 2
|
||||
_DERIVE_MIN_NAME = 3
|
||||
# CSS is never grouped by body (note 2917): classes for different purposes
|
||||
# share declarations because the style system makes them alike — `.text-muted`
|
||||
# and `.pin-badge-auto` carrying the same `color: var(--fs-text-tertiary)` are
|
||||
# two meanings, not two copies. A CSS family is a NAME defined in more than
|
||||
# one file: that is a recipe living in several places, and two is already
|
||||
# the signal (a class name is deliberate in a way `setup`/`load` are not).
|
||||
_DERIVE_MIN_NAME_CSS = 2
|
||||
# Semantic checks per repo per refresh — an embedding each (local fastembed),
|
||||
# bounded so a 4,000-row ledger is worked through over refreshes, not in one.
|
||||
_SEMANTIC_CAP = 150
|
||||
@@ -1298,15 +1427,16 @@ def derive_groups(
|
||||
rows: Iterable[tuple[str, str, str, str]]
|
||||
) -> dict[tuple[str, str, str], str]:
|
||||
"""The derive-first grouping over (path, kind, symbol, body_sha) rows
|
||||
that matched no canon: {(path, kind, symbol): group_key}. Identical
|
||||
bodies in ≥2 places group as `dup:<sha>`; the same name defined in ≥3
|
||||
files groups as `name:<kind>:<symbol>`; a row joins at most one group,
|
||||
the copy before the name."""
|
||||
that matched no canon: {(path, kind, symbol): group_key}. For code
|
||||
(kind `sym`) identical bodies in ≥2 places group as `dup:<sha>` and the
|
||||
same name defined in ≥3 files groups as `name:sym:<symbol>`, the copy
|
||||
before the name. CSS groups by name only — the same class defined in
|
||||
≥2 files is `name:css:<symbol>`; its body never groups it (note 2917)."""
|
||||
by_sha: dict[str, list[tuple[str, str, str]]] = {}
|
||||
by_name: dict[tuple[str, str], list[tuple[str, str, str]]] = {}
|
||||
for path, kind, symbol, sha in rows:
|
||||
key = (path, kind, symbol)
|
||||
if sha:
|
||||
if sha and kind != "css":
|
||||
by_sha.setdefault(sha, []).append(key)
|
||||
by_name.setdefault((kind, _norm_symbol(symbol)), []).append(key)
|
||||
out: dict[tuple[str, str, str], str] = {}
|
||||
@@ -1315,7 +1445,8 @@ def derive_groups(
|
||||
for key in keys:
|
||||
out.setdefault(key, f"dup:{sha}")
|
||||
for (kind, symbol), keys in by_name.items():
|
||||
if len({k[0] for k in keys}) >= _DERIVE_MIN_NAME:
|
||||
floor = _DERIVE_MIN_NAME_CSS if kind == "css" else _DERIVE_MIN_NAME
|
||||
if len({k[0] for k in keys}) >= floor:
|
||||
for key in keys:
|
||||
out.setdefault(key, f"name:{kind}:{symbol}")
|
||||
return out
|
||||
@@ -1360,13 +1491,21 @@ async def apply_derive_groups(project_id: int) -> int:
|
||||
return grouped
|
||||
|
||||
|
||||
def proposal_summary(rows: Iterable[CodeShape], *, top: int = 8) -> dict:
|
||||
def proposal_summary(
|
||||
rows: Iterable[CodeShape], *, top: int = 8,
|
||||
consumer_paths: dict[int, list[str]] | None = None,
|
||||
) -> dict:
|
||||
"""The readout's view of the proposer's standing: how many canon
|
||||
proposals await confirmation, and the largest derive-first groups."""
|
||||
proposals await confirmation, and the largest derive-first groups.
|
||||
``consumer_paths`` (shape_id → files whose markup names it, milestone
|
||||
302) puts `consumers` on each group — the family's distinct consumer
|
||||
files across its members, the datum that separates a shared recipe
|
||||
from a scoped convention."""
|
||||
proposed = 0
|
||||
by_canon: dict[int, int] = {}
|
||||
groups: dict[str, dict] = {}
|
||||
files: dict[str, set[str]] = {}
|
||||
consumers: dict[str, set[str]] = {}
|
||||
for row in rows:
|
||||
if row.status not in _MECHANICAL_TODO:
|
||||
continue
|
||||
@@ -1387,8 +1526,14 @@ def proposal_summary(rows: Iterable[CodeShape], *, top: int = 8) -> dict:
|
||||
files.setdefault(row.proposal_group, set()).add(row.path)
|
||||
if len(g["paths"]) < 3:
|
||||
g["paths"].append(row.path)
|
||||
if consumer_paths is not None and row.kind == "css":
|
||||
consumers.setdefault(row.proposal_group, set()).update(
|
||||
consumer_paths.get(row.id) or ()
|
||||
)
|
||||
for key, g in groups.items():
|
||||
g["files"] = len(files[key])
|
||||
if key in consumers:
|
||||
g["consumers"] = consumer_summary(consumers[key])
|
||||
# Body-identical groups first (#2872): the things an audit actually
|
||||
# consolidated were identical bodies under different names/files; a
|
||||
# name repeated across modules is usually convention. Within a tier,
|
||||
@@ -1404,6 +1549,34 @@ def proposal_summary(rows: Iterable[CodeShape], *, top: int = 8) -> dict:
|
||||
return {"proposed": proposed, "derive_groups": ranked[:top], "top_canon": top_canon}
|
||||
|
||||
|
||||
def derive_new_summary(
|
||||
rows: Iterable[CodeShape], *, since: datetime | None, top: int = 3
|
||||
) -> dict:
|
||||
"""The arrival-moment drift signal (#2899): derive-grouped rows FIRST
|
||||
SEEN after ``since`` — the previous refresh's stamp, the same one
|
||||
flag_divergence uses. "Since the last refresh, N more copies joined a
|
||||
duplicate family" is the sentence that makes the derive queue a thing
|
||||
you notice on entering, not a thing an audit finds. ``since`` None (a
|
||||
first seed) means nothing is new. Judged rows never count."""
|
||||
if since is None:
|
||||
return {"count": 0, "examples": []}
|
||||
fresh = [
|
||||
r for r in rows
|
||||
if r.proposal_basis == "derive" and r.proposal_group
|
||||
and r.status in _MECHANICAL_TODO and r.vanished_at is None
|
||||
and r.created_at is not None and r.created_at > since
|
||||
]
|
||||
fresh.sort(key=lambda r: r.created_at, reverse=True)
|
||||
return {
|
||||
"count": len(fresh),
|
||||
"examples": [
|
||||
{"label": ("." if r.kind == "css" else "") + r.symbol,
|
||||
"path": r.path, "group": r.proposal_group}
|
||||
for r in fresh[:top]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def confirm_proposals(
|
||||
user_id: int,
|
||||
project_id: int,
|
||||
@@ -1566,6 +1739,82 @@ async def write_time_divergence(
|
||||
return out
|
||||
|
||||
|
||||
# How many other files a family line names before "…" — enough to go look,
|
||||
# not a wall.
|
||||
_DERIVE_FILES_SHOWN = 4
|
||||
|
||||
|
||||
async def write_time_derive(
|
||||
project_id: int, path: str, shapes: list[tuple[str, str]]
|
||||
) -> list[dict]:
|
||||
"""The in-band DERIVE check (#2900): for each (kind, name) the hook
|
||||
named at ``path``, what the ledger already knows about that name
|
||||
elsewhere in the project —
|
||||
|
||||
family the name sits in a derive-first group (code: identical body
|
||||
in N files or the same name in ≥3; CSS: the same class in
|
||||
≥2 files, note 2917): "this is a known family with no canon
|
||||
— derive it now, don't add a copy";
|
||||
canon a `canonical` row of that name at another path: "this is
|
||||
canon #N at <path> — reuse, don't redefine".
|
||||
|
||||
Only for shapes not yet judged at ``path`` (a judged shape is not
|
||||
re-litigated at every edit), never for the canon's own file. Returns
|
||||
[{symbol, kind, key, family?|canon?}] — `key` is the dedup token the
|
||||
hook keeps per session (the group id, or canon:<snippet_id>)."""
|
||||
wanted = {(k, _norm_symbol(n)): n for k, n in shapes if n}
|
||||
if not wanted:
|
||||
return []
|
||||
async with async_session() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(CodeShape).where(
|
||||
CodeShape.project_id == project_id,
|
||||
CodeShape.vanished_at.is_(None),
|
||||
CodeShape.symbol.in_({norm for (_k, norm) in wanted}),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
out: list[dict] = []
|
||||
for (kind, norm), name in wanted.items():
|
||||
same = [r for r in rows if r.kind == kind and _norm_symbol(r.symbol) == norm]
|
||||
here = next((r for r in same if r.path == path), None)
|
||||
if here is not None and here.status not in _MECHANICAL_TODO:
|
||||
continue # judged here (or this IS the canon): nothing to say
|
||||
others = [r for r in same if r.path != path]
|
||||
label = ("." if kind == "css" else "") + name
|
||||
canon = next((r for r in others if r.status == "canonical" and r.snippet_id), None)
|
||||
if canon is not None:
|
||||
out.append({"symbol": name, "kind": kind, "key": f"canon:{canon.snippet_id}",
|
||||
"canon": {"snippet_id": canon.snippet_id, "path": canon.path,
|
||||
"label": label}})
|
||||
continue
|
||||
grouped = [r for r in others if r.proposal_group and r.status in _MECHANICAL_TODO]
|
||||
if here is not None and here.proposal_group:
|
||||
grouped = [r for r in grouped if r.proposal_group == here.proposal_group] or grouped
|
||||
if not grouped:
|
||||
continue
|
||||
group = grouped[0].proposal_group
|
||||
members = [r for r in grouped if r.proposal_group == group]
|
||||
files = sorted({r.path for r in members})
|
||||
family = {
|
||||
"group": group, "label": label,
|
||||
"identical": not group.startswith("name:"),
|
||||
"files": files[:_DERIVE_FILES_SHOWN], "file_count": len(files),
|
||||
"size": len(members) + (1 if here is not None else 0),
|
||||
}
|
||||
if kind == "css":
|
||||
# What renders the family (milestone 302): the members' consumer
|
||||
# files, the row at `path` included when it already exists.
|
||||
ids = [r.id for r in members] + ([here.id] if here is not None else [])
|
||||
edges = await consumers_of(ids)
|
||||
family["consumers"] = consumer_summary(
|
||||
e.path for es in edges.values() for e in es
|
||||
)
|
||||
out.append({"symbol": name, "kind": kind, "key": group, "family": family})
|
||||
return out
|
||||
|
||||
|
||||
async def flag_divergence(project_id: int, *, since: datetime | None) -> int:
|
||||
"""Flag shapes created after ``since`` (the previous refresh) that sit
|
||||
where a canon dominates and were not proposed as that canon. With no
|
||||
|
||||
@@ -6,6 +6,7 @@ them; a module imports what it needs with ``from tests.helpers import ...``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
@@ -182,3 +183,39 @@ def design_token_stub(name, value_by_mode, group_name=None, purpose=None,
|
||||
name=name, value_by_mode=value_by_mode, group_name=group_name,
|
||||
purpose=purpose, order_index=order_index, supersedes=supersedes or [],
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def http_sink(reply: bytes = b'{"context":"","note_ids":[]}'):
|
||||
"""A throwaway local HTTP listener for hook end-to-end tests: yields
|
||||
``(port, seen)`` where ``seen`` collects every GET's parsed query string
|
||||
(one dict per request, in order). Lets the shell be tested end to end —
|
||||
the extraction, the encoding, the URL — without a Scribe instance.
|
||||
|
||||
Three test modules each carried their own ``_Sink`` handler before #2904
|
||||
consolidated them here; pass ``reply`` for the body the hook should see.
|
||||
"""
|
||||
import http.server
|
||||
import threading
|
||||
import urllib.parse
|
||||
|
||||
seen: list[dict] = []
|
||||
|
||||
class _Sink(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
seen.append(urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query))
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(reply)
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
server = http.server.HTTPServer(("127.0.0.1", 0), _Sink)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
try:
|
||||
yield server.server_port, seen
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""The PostToolUse after-write hook (#2901): code written through Bash — sed,
|
||||
heredocs, scripts — gets the same prior-art / ledger checks as a Write/Edit.
|
||||
|
||||
Runs the real shell against a temp git repo and a throwaway HTTP sink, like
|
||||
the pre-write hook's end-to-end tests. Skips where the hook's tools are
|
||||
missing; asserts on content where they are present."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers import http_sink
|
||||
|
||||
PLUGIN = Path(__file__).resolve().parents[1] / "plugin"
|
||||
HOOK = PLUGIN / "hooks" / "scribe_after_write.sh"
|
||||
|
||||
|
||||
def _env(tmp_path, url="http://127.0.0.1:9"):
|
||||
for tool in ("git", "jq", "curl", "bash"):
|
||||
if shutil.which(tool) is None:
|
||||
pytest.skip(f"hook runtime tool {tool!r} not installed")
|
||||
return {"PATH": os.environ["PATH"], "SCRIBE_URL": url, "SCRIBE_TOKEN": "t",
|
||||
"TMPDIR": str(tmp_path), "HOME": str(tmp_path),
|
||||
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@x",
|
||||
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@x"}
|
||||
|
||||
|
||||
def _repo(tmp_path, env):
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env)
|
||||
(repo / "b.py").write_text("def one():\n return 1\n")
|
||||
(repo / "c.py").write_text("def slug(t):\n return t.lower()\n")
|
||||
subprocess.run(["git", "add", "."], cwd=repo, check=True, env=env)
|
||||
subprocess.run(["git", "commit", "-q", "-m", "base"], cwd=repo, check=True, env=env)
|
||||
return repo
|
||||
|
||||
|
||||
def _run(repo, env, session="s-after-1", tool="Bash"):
|
||||
out = subprocess.run(
|
||||
["bash", str(HOOK)],
|
||||
input=json.dumps({"session_id": session, "cwd": str(repo), "tool_name": tool,
|
||||
"tool_input": {"command": "cat > x"}, "tool_response": {}}),
|
||||
capture_output=True, text=True, env=env,
|
||||
)
|
||||
assert out.returncode == 0, out.stderr
|
||||
return out.stdout
|
||||
|
||||
|
||||
SINK_REPLY = b'{"context":"> family named","note_ids":[],"sync_note_ids":[],"derive_keys":["dup:483a"]}'
|
||||
|
||||
|
||||
def test_after_write_names_what_bash_just_wrote_then_stays_quiet_until_the_next_change(tmp_path):
|
||||
with http_sink(SINK_REPLY) as (port, seen):
|
||||
env = _env(tmp_path, url=f"http://127.0.0.1:{port}")
|
||||
repo = _repo(tmp_path, env)
|
||||
# "A Bash call" wrote an untracked stylesheet and appended to a tracked file.
|
||||
(repo / "a.css").write_text(".log-empty {\n color: red;\n}\n")
|
||||
(repo / "b.py").write_text("def one():\n return 1\n\ndef slug(t):\n return t\n")
|
||||
out = _run(repo, env)
|
||||
by_path = {q["path"][0]: q for q in seen}
|
||||
assert set(by_path) == {"a.css", "b.py"} # repo-relative, like the pre hook
|
||||
assert by_path["a.css"]["shapes"] == ["css:log-empty"]
|
||||
assert by_path["b.py"]["shapes"] == ["sym:slug"]
|
||||
# Added lines only for the tracked file — the existing def is not "just written".
|
||||
assert "def slug" in by_path["b.py"]["code"][0] and "def one" not in by_path["b.py"]["code"][0]
|
||||
ctx = json.loads(out)["hookSpecificOutput"]
|
||||
assert ctx["hookEventName"] == "PostToolUse"
|
||||
assert "> family named" in ctx["additionalContext"]
|
||||
# The local by-name arm rides along: `slug` already lives in c.py.
|
||||
assert "`slug` is already defined in 1 other file(s): c.py" in ctx["additionalContext"]
|
||||
# Derive keys landed on the SHARED channel the pre-write hook reads.
|
||||
state = tmp_path / "scribe-priorart" / "s-after-1.derive.ids"
|
||||
assert "dup:483a" in state.read_text().split()
|
||||
|
||||
# Nothing changed → one git status, no request, no output.
|
||||
seen.clear()
|
||||
assert _run(repo, env) == ""
|
||||
assert seen == []
|
||||
|
||||
# Another change → only that file, and the dedup channel goes back up.
|
||||
(repo / "a.css").write_text(".log-empty {\n color: red;\n}\n.other {\n margin: 0;\n}\n")
|
||||
_run(repo, env)
|
||||
assert [q["path"][0] for q in seen] == ["a.css"]
|
||||
assert seen[0]["exclude_derive"] == ["dup:483a"]
|
||||
assert set(seen[0]["shapes"][0].split(",")) == {"css:log-empty", "css:other"}
|
||||
|
||||
def test_after_write_is_silent_where_it_has_nothing_to_say(tmp_path):
|
||||
env = _env(tmp_path)
|
||||
repo = _repo(tmp_path, env)
|
||||
# Not a Bash call → nothing (hooks.json matches Bash, the script re-checks).
|
||||
(repo / "a.css").write_text(".x {\n color: red;\n}\n")
|
||||
assert _run(repo, env, tool="Write") == ""
|
||||
# Not a git repo → nothing.
|
||||
loose = tmp_path / "loose"
|
||||
loose.mkdir()
|
||||
(loose / "a.css").write_text(".x {\n color: red;\n}\n")
|
||||
assert _run(loose, env, session="s-loose") == ""
|
||||
# A change that defines nothing (prose, a call-site edit) → nothing, even
|
||||
# with the server unreachable (port 9 refuses): no definitions, no call
|
||||
# owed, so not even the #2932 outage line. (a.css above is removed first:
|
||||
# it DOES define a shape, and an unanswered call for it would rightly speak.)
|
||||
(repo / "a.css").unlink()
|
||||
(repo / "README.md").write_text("# notes\n")
|
||||
(repo / "b.py").write_text("def one():\n return one_more()\n")
|
||||
assert _run(repo, env, session="s-quiet") == ""
|
||||
|
||||
|
||||
def test_after_write_local_arm_works_without_a_server_and_says_the_server_did_not_answer(tmp_path):
|
||||
"""The local by-name arm needs no instance (#2280). A configured instance
|
||||
that does not ANSWER (a refused connection stands in for it) is said, once
|
||||
per outage (#2932) — and the record nudge, which claims "nothing recorded",
|
||||
is withheld: no answer backs that claim."""
|
||||
env = _env(tmp_path)
|
||||
repo = _repo(tmp_path, env)
|
||||
(repo / "d.py").write_text("def slug(t):\n return t.lower()\n")
|
||||
out = _run(repo, env, session="s-local")
|
||||
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
|
||||
assert "`slug` is already defined in 1 other file(s): c.py" in ctx
|
||||
assert "Scribe did not answer the prior-art check for `d.py` within 8s" in ctx
|
||||
assert "UNCHECKED" in ctx
|
||||
assert "None of those existing copies is recorded" not in ctx
|
||||
marker = tmp_path / "scribe-priorart" / "s-local.unreached"
|
||||
assert marker.is_file() and marker.read_text().isdigit()
|
||||
# Still down a moment later: the local arm speaks, the outage line does not
|
||||
# repeat (once per outage, not once per write).
|
||||
(repo / "e.py").write_text("def slug(t):\n return t.upper()\n")
|
||||
out = _run(repo, env, session="s-local")
|
||||
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
|
||||
assert "`slug` is already defined in" in ctx
|
||||
assert "did not answer" not in ctx
|
||||
|
||||
|
||||
def test_after_write_unconfigured_install_owes_no_call_and_keeps_the_record_nudge(tmp_path):
|
||||
"""No URL/token → no call was owed, so nothing is "unreached"; the local
|
||||
arm and the record nudge (#2664) stand on their own, as before."""
|
||||
env = {k: v for k, v in _env(tmp_path).items() if k not in ("SCRIBE_URL", "SCRIBE_TOKEN")}
|
||||
repo = _repo(tmp_path, env)
|
||||
(repo / "d.py").write_text("def slug(t):\n return t.lower()\n")
|
||||
out = _run(repo, env, session="s-unconf")
|
||||
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
|
||||
assert "`slug` is already defined in 1 other file(s): c.py" in ctx
|
||||
assert "create_snippet" in ctx
|
||||
assert "did not answer" not in ctx
|
||||
assert not (tmp_path / "scribe-priorart" / "s-unconf.unreached").exists()
|
||||
@@ -588,13 +588,161 @@ async def test_derive_groups_land_on_rows_and_in_the_summary(seeded):
|
||||
assert summary["derive_groups"][1]["label"] == ".card"
|
||||
assert summary["derive_groups"][0]["size"] == 2 and summary["derive_groups"][1]["size"] == 3
|
||||
|
||||
# One of the css copies gets judged → the group shrinks on the next pass.
|
||||
# One of the css copies gets judged → the group shrinks on the next pass
|
||||
# but stays a family: a class in two files is already a recipe living in
|
||||
# two places (css name floor 2, note 2917). Judge the second and it's gone.
|
||||
await classify_shapes(owner, pid, [
|
||||
{"path": "b/z.css", "symbol": "card", "status": "exempt", "reason": "print sheet"},
|
||||
])
|
||||
await apply_derive_groups(pid)
|
||||
rows, _ = await list_project_shapes(owner, pid, proposal="derive")
|
||||
assert {r.symbol for r in rows} == {"slug"} # 2 files < the name floor
|
||||
assert {r.symbol for r in rows} == {"slug", "card"}
|
||||
assert {r.path for r in rows if r.symbol == "card"} == {"b/x.css", "b/y.css"}
|
||||
await classify_shapes(owner, pid, [
|
||||
{"path": "b/y.css", "symbol": "card", "status": "exempt", "reason": "print sheet"},
|
||||
])
|
||||
await apply_derive_groups(pid)
|
||||
rows, _ = await list_project_shapes(owner, pid, proposal="derive")
|
||||
assert {r.symbol for r in rows} == {"slug"} # 1 file < the css name floor
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_write_time_derive_names_the_family_or_the_canon_for_a_name(seeded):
|
||||
"""#2900: against real rows — a name in a family → the family (other
|
||||
files, count; for CSS a NAME family, never a body one — note 2917); a
|
||||
name whose canonical row lives elsewhere → that canon; a judged row at
|
||||
the path, the canon's own file, or an unknown name → silence."""
|
||||
from scribe.services.shape_ledger import apply_derive_groups, write_time_derive
|
||||
|
||||
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
|
||||
defs = _defs(
|
||||
("v/A.vue", "css", "log-empty", ".log-empty {", ".log-empty { color: red }"),
|
||||
("v/B.vue", "css", "log-empty", ".log-empty {", ".log-empty { color: red }"),
|
||||
("v/C.vue", "css", "log-empty", ".log-empty {", ".log-empty { color: red }"),
|
||||
("src/factory.py", "sym", "factory", "def factory():", "def factory():\n return 1"),
|
||||
)
|
||||
await sync_repo_shapes(pid, REPO, defs, seen_marker="m1")
|
||||
await classify_shapes(owner, pid, [
|
||||
{"path": "src/factory.py", "symbol": "factory", "status": "canonical", "snippet_id": sid},
|
||||
])
|
||||
assert await apply_derive_groups(pid) >= 3
|
||||
|
||||
# A 4th copy about to be written → the family, naming the other files.
|
||||
out = await write_time_derive(pid, "v/D.vue", [("css", "log-empty"), ("css", "unknown")])
|
||||
assert len(out) == 1 and out[0]["symbol"] == "log-empty" and out[0]["kind"] == "css"
|
||||
fam = out[0]["family"]
|
||||
# Three identical bodies, and still a NAME family: CSS never groups by body.
|
||||
assert fam["identical"] is False and fam["label"] == ".log-empty"
|
||||
assert fam["files"] == ["v/A.vue", "v/B.vue", "v/C.vue"] and fam["file_count"] == 3
|
||||
assert out[0]["key"] == fam["group"] == "name:css:log-empty"
|
||||
# Editing one existing member still names the OTHER members.
|
||||
out = await write_time_derive(pid, "v/A.vue", [("css", "log-empty")])
|
||||
assert out[0]["family"]["files"] == ["v/B.vue", "v/C.vue"] and out[0]["family"]["size"] == 3
|
||||
# The canon's name elsewhere → the canon; in the canon's own file → silence.
|
||||
out = await write_time_derive(pid, "src/other.py", [("sym", "factory")])
|
||||
assert out == [{"symbol": "factory", "kind": "sym", "key": f"canon:{sid}",
|
||||
"canon": {"snippet_id": sid, "path": "src/factory.py", "label": "factory"}}]
|
||||
assert await write_time_derive(pid, "src/factory.py", [("sym", "factory")]) == []
|
||||
# A judged row at the path is not re-litigated.
|
||||
await classify_shapes(owner, pid, [
|
||||
{"path": "v/B.vue", "symbol": "log-empty", "status": "exempt", "reason": "print sheet"},
|
||||
])
|
||||
assert await write_time_derive(pid, "v/B.vue", [("css", "log-empty")]) == []
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_derive_new_names_the_copy_that_joined_a_family_since_the_stamp(seeded):
|
||||
"""#2899: the first sync seeds one `slug`; a later sync adds an identical
|
||||
copy. Against the stamp between them, derive_new counts ONLY the
|
||||
newcomer — the drift since the last refresh, not the whole family."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from scribe.services.shape_ledger import (
|
||||
apply_derive_groups, derive_new_summary, live_rows,
|
||||
)
|
||||
|
||||
owner, pid = seeded["owner"], seeded["pid"]
|
||||
first = _defs(
|
||||
("a/one.py", "sym", "slug", "def slug(t):", "def slug(t):\n return t.lower()"),
|
||||
)
|
||||
await sync_repo_shapes(pid, REPO, first, seen_marker="m1")
|
||||
stamp = datetime.now(timezone.utc)
|
||||
second = _defs(
|
||||
("a/one.py", "sym", "slug", "def slug(t):", "def slug(t):\n return t.lower()"),
|
||||
("a/two.py", "sym", "slug", "def slug(t):", "def slug(t):\n return t.lower()"),
|
||||
)
|
||||
await sync_repo_shapes(pid, REPO, second, seen_marker="m2")
|
||||
assert await apply_derive_groups(pid) == 2
|
||||
|
||||
rows = await live_rows(pid)
|
||||
out = derive_new_summary(rows, since=stamp)
|
||||
assert out["count"] == 1
|
||||
assert out["examples"][0]["path"] == "a/two.py"
|
||||
assert out["examples"][0]["label"] == "slug"
|
||||
assert out["examples"][0]["group"].startswith("dup:")
|
||||
assert derive_new_summary(rows, since=None)["count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_consumer_map_syncs_edges_from_template_references(seeded):
|
||||
"""Milestone 302: the consumer edges follow the archive — own-file
|
||||
resolution for a scoped class, fan-out to the shared sheet for a class a
|
||||
template does not define, counts refreshed and stale edges removed on
|
||||
the next sync, and a vanished row's edges gone with it."""
|
||||
from scribe.services.shape_ledger import consumers_of, live_rows, sync_repo_consumers
|
||||
|
||||
pid = seeded["pid"]
|
||||
defs = _defs(
|
||||
("v/A.vue", "css", "error-msg", ".error-msg {", ".error-msg { color: red }"),
|
||||
("v/B.vue", "css", "error-msg", ".error-msg {", ".error-msg { color: blue }"),
|
||||
("assets/components.css", "css", "btn-primary", ".btn-primary {", ".btn-primary { x: 1 }"),
|
||||
("assets/orphan.css", "css", "orphan", ".orphan {", ".orphan { y: 2 }"), # no template names it
|
||||
)
|
||||
await sync_repo_shapes(pid, REPO, defs, seen_marker="m1")
|
||||
refs = {
|
||||
"v/A.vue": {"error-msg": 2, "btn-primary": 1},
|
||||
"v/B.vue": {"error-msg": 1},
|
||||
"v/C.vue": {"error-msg": 1, "btn-primary": 4},
|
||||
}
|
||||
# A and B consume their OWN error-msg; C defines none, so its use fans
|
||||
# out to both rows; btn-primary resolves to the shared sheet from A and C.
|
||||
assert await sync_repo_consumers(pid, REPO, refs) == 6
|
||||
rows = {(r.path, r.symbol): r.id for r in await live_rows(pid) if r.kind == "css"}
|
||||
edges = await consumers_of(rows.values())
|
||||
view = {(p, s): [(e.path, e.count) for e in edges.get(i, [])] for (p, s), i in rows.items()}
|
||||
assert view[("v/A.vue", "error-msg")] == [("v/A.vue", 2), ("v/C.vue", 1)]
|
||||
assert view[("v/B.vue", "error-msg")] == [("v/B.vue", 1), ("v/C.vue", 1)]
|
||||
assert view[("assets/components.css", "btn-primary")] == [("v/A.vue", 1), ("v/C.vue", 4)]
|
||||
assert view[("assets/orphan.css", "orphan")] == []
|
||||
|
||||
# The next tree: C stops using error-msg, A uses btn-primary twice now.
|
||||
refs2 = {"v/A.vue": {"error-msg": 2, "btn-primary": 2}, "v/B.vue": {"error-msg": 1}}
|
||||
assert await sync_repo_consumers(pid, REPO, refs2) == 3
|
||||
edges = await consumers_of(rows.values())
|
||||
assert [(e.path, e.count) for e in edges[rows[("v/B.vue", "error-msg")]]] == [("v/B.vue", 1)]
|
||||
assert [(e.path, e.count) for e in edges[rows[("assets/components.css", "btn-primary")]]] == [("v/A.vue", 2)]
|
||||
|
||||
# The readout side: used_by per css row, the unused-css flag, and the
|
||||
# family's consumers on the write-path check.
|
||||
from scribe.services.shape_ledger import (
|
||||
apply_derive_groups, used_by_map, write_time_derive,
|
||||
)
|
||||
owner = seeded["owner"]
|
||||
live = [r for r in await live_rows(pid) if r.kind == "css"]
|
||||
used = await used_by_map(live)
|
||||
assert used[rows[("v/B.vue", "error-msg")]] == {"count": 1, "paths": ["v/B.vue"]}
|
||||
assert used[rows[("assets/orphan.css", "orphan")]] == {"count": 0, "paths": []}
|
||||
unused, n = await list_project_shapes(owner, pid, flag="unused-css")
|
||||
assert n == 1 and [(r.path, r.symbol) for r in unused] == [("assets/orphan.css", "orphan")]
|
||||
await apply_derive_groups(pid)
|
||||
out = await write_time_derive(pid, "v/New.vue", [("css", "error-msg")])
|
||||
assert out and out[0]["family"]["consumers"] == {"count": 2, "paths": ["v/A.vue", "v/B.vue"]}
|
||||
|
||||
# B's rule vanishes from the tree → its edges go with the pass.
|
||||
await sync_repo_shapes(pid, REPO, [d for d in defs if d[0] != "v/B.vue"], seen_marker="m2")
|
||||
await sync_repo_consumers(pid, REPO, refs2)
|
||||
edges = await consumers_of(rows.values())
|
||||
assert rows[("v/B.vue", "error-msg")] not in edges
|
||||
|
||||
|
||||
# --- #2793: the divergence readout against real rows -------------------------
|
||||
|
||||
@@ -16,7 +16,10 @@ import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from scribe.services.coverage import (
|
||||
ArchiveScan,
|
||||
class_references,
|
||||
coverage_line,
|
||||
scan_archive,
|
||||
extract_shapes,
|
||||
largest_gaps,
|
||||
scannable,
|
||||
@@ -118,6 +121,80 @@ def test_shapes_from_archive_strips_the_wrapper_and_gates_files():
|
||||
assert shapes_from_archive(_tarball(TREE)) == TREE_SHAPES
|
||||
|
||||
|
||||
# --- unit: template class references — the CSS consumer map (milestone 302) --
|
||||
|
||||
|
||||
def test_class_references_reads_vue_static_and_dynamic_forms_only():
|
||||
"""A template's class attributes name the classes it consumes: the static
|
||||
`class=`, the Vue dynamic object/array/ternary forms (string literals and
|
||||
bare object keys), never a selector in <style>, a `class Foo` in
|
||||
<script>, a `querySelector('.x')`, or a look-alike attribute."""
|
||||
vue = (
|
||||
"<template>\n"
|
||||
' <div class="card card--wide" :class="{ active: isOpen, \'is-error\': err }">\n'
|
||||
' <span :class="[ \'pill\', cond ? \'pill-on\' : \'pill-off\', other ]" />\n'
|
||||
' <p class="card" v-bind:class="open ? openCls : \'closed\'">{{ t }}</p>\n'
|
||||
' <i data-class="nope" headerClass="nope2" />\n'
|
||||
" </div>\n"
|
||||
"</template>\n"
|
||||
'<script setup lang="ts">\n'
|
||||
"class Foo {}\n"
|
||||
"const el = document.querySelector('.zap')\n"
|
||||
"</script>\n"
|
||||
"<style scoped>\n"
|
||||
".card { color: red; }\n"
|
||||
".zap { color: blue; }\n"
|
||||
"</style>\n"
|
||||
)
|
||||
assert class_references("a/B.vue", vue) == {
|
||||
"card": 2, "card--wide": 1, "active": 1, "is-error": 1,
|
||||
"pill": 1, "pill-on": 1, "pill-off": 1, "closed": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_class_references_reads_react_svelte_and_server_templates():
|
||||
tsx = (
|
||||
"export function X({ on }: { on: boolean }) {\n"
|
||||
' return <button className="btn btn-primary" data-x="y">\n'
|
||||
" <i className={on ? 'tab tab-on' : 'tab'} />\n"
|
||||
" <b className={`chip ${on ? 'chip-on' : ''} chip-sm`} />\n"
|
||||
" <u className={cn({ pill: on, 'pill-off': !on })} />\n"
|
||||
" </button>\n"
|
||||
"}\n"
|
||||
)
|
||||
# A template literal's static text counts; its `${…}` hole is unknowable
|
||||
# (chip-on sits inside the hole's own ternary and is NOT claimed).
|
||||
assert class_references("a/x.tsx", tsx) == {
|
||||
"btn": 1, "btn-primary": 1, "tab": 2, "tab-on": 1,
|
||||
"chip": 1, "chip-sm": 1, "pill": 1, "pill-off": 1,
|
||||
}
|
||||
assert class_references("a/y.svelte", '<div class:active={on} class="row">') == {
|
||||
"row": 1, "active": 1,
|
||||
}
|
||||
# A server-side interpolation contributes no token; a literal class inside
|
||||
# a template conditional still does.
|
||||
html = '<div class="row {{ cls }} col-2 {% if x %}y{% endif %}">'
|
||||
assert class_references("t/p.html", html) == {"row": 1, "col-2": 1, "y": 1}
|
||||
# Not a template-bearing file: nothing, however it reads.
|
||||
assert class_references("a/z.py", 'html = \'<div class="row">\'') == {}
|
||||
|
||||
|
||||
def test_scan_archive_returns_definitions_and_references_from_one_walk():
|
||||
tree = dict(TREE)
|
||||
tree["web/Card.vue"] = (
|
||||
b'<template><div class="btn card">x</div></template>\n'
|
||||
b"<style scoped>\n.card {\n color: red;\n}\n</style>\n"
|
||||
)
|
||||
scan = scan_archive(_tarball(tree))
|
||||
assert isinstance(scan, ArchiveScan)
|
||||
assert [(d.path, d.kind, d.name) for d in scan.definitions] == TREE_SHAPES + [
|
||||
("web/Card.vue", "css", "card"),
|
||||
]
|
||||
# Only files whose markup names a class appear; the .py/.css files don't.
|
||||
assert scan.references == {"web/Card.vue": {"btn": 1, "card": 1}}
|
||||
assert shapes_from_archive(_tarball(tree)) == [(d.path, d.kind, d.name) for d in scan.definitions]
|
||||
|
||||
|
||||
# --- unit: the covering predicate (lives with the ledger since #2788) --------
|
||||
|
||||
|
||||
@@ -149,6 +226,21 @@ def test_largest_gaps_ranks_by_unclassified_and_drops_clean_dirs():
|
||||
assert gaps == [{"dir": "src", "unclassified": 2, "total": 3}]
|
||||
|
||||
|
||||
def test_type_import_specifiers_are_not_definitions():
|
||||
"""#2904: `import { type Foo, bar }` is the same two words as `type Foo =`
|
||||
and defines nothing; only a `type` line with a declaration after the
|
||||
name counts (TS alias, Go/Rust type)."""
|
||||
from scribe.services.coverage import extract_shapes
|
||||
src = (
|
||||
'import { type DesignSystem, fetchDesignSystems } from "@/api/designSystems";\n'
|
||||
'import { type Project } from "./x";\n'
|
||||
"type Baz = { a: number };\n"
|
||||
"type Wide<T> = T | null;\n"
|
||||
"type Point struct {\n\tX int\n}\n"
|
||||
)
|
||||
assert extract_shapes(src) == [("sym", "Baz"), ("sym", "Wide"), ("sym", "Point")]
|
||||
|
||||
|
||||
def test_coverage_line_is_evidence_carrying_and_labeled_estimate():
|
||||
line = coverage_line({
|
||||
"total": 4573, "accounted": 3100, "unclassified": 1473,
|
||||
@@ -276,6 +368,8 @@ async def test_coverage_measures_the_tree_exactly_and_caches(seeded):
|
||||
assert coverage["largest_gaps"] == [
|
||||
{"dir": "src", "unclassified": 2, "total": 3}
|
||||
]
|
||||
# #2899: a first computation has no previous stamp — nothing is "new".
|
||||
assert coverage["derive_new"] == {"count": 0, "examples": []}
|
||||
|
||||
# The walk fed the LEDGER (#2788): every extracted shape has a row, the
|
||||
# snippet reference locations are mechanically stamped canonical WITH
|
||||
@@ -482,12 +576,18 @@ def test_extract_definitions_fingerprints_each_block():
|
||||
d = {x.name: x for x in extract_definitions(css)}
|
||||
assert d["closed-msg"].body_sha == d["error-block"].body_sha != d["other"].body_sha
|
||||
# One-line rules hash their own declarations — never the empty string
|
||||
# (first deploy grouped 68 unrelated one-liners as one copy).
|
||||
one = ".a { color: red; }\n\n.b { color: red; }\n\n.c { color: blue; }\n\n.d {\n color: red;\n}\n"
|
||||
# (first deploy grouped 68 unrelated one-liners as one copy) — and a
|
||||
# SINGLE declaration is not a shape (#2903): it keeps its selector in the
|
||||
# hash, so `.a { color: red }` groups only with another `.a`, never with
|
||||
# `.b { color: red }`. Two declarations and up stay selector-agnostic.
|
||||
one = ".a { color: red; }\n\n.b { color: red; }\n\n.c { color: blue; }\n\n.a {\n color: red;\n}\n"
|
||||
e = {x.name: x for x in extract_definitions(one)}
|
||||
import hashlib
|
||||
assert e["a"].body_sha == e["b"].body_sha != e["c"].body_sha
|
||||
assert e["a"].body_sha != e["b"].body_sha != e["c"].body_sha
|
||||
assert e["a"].body_sha != hashlib.sha1(b"").hexdigest()[:16]
|
||||
two = ".a {\n color: red;\n margin: 0;\n}\n.b {\n color: red;\n margin: 0;\n}\n"
|
||||
f = {x.name: x for x in extract_definitions(two)}
|
||||
assert f["a"].body_sha == f["b"].body_sha
|
||||
|
||||
|
||||
def test_coverage_line_names_the_proposers_standing():
|
||||
@@ -504,6 +604,17 @@ def test_coverage_line_names_the_proposers_standing():
|
||||
assert "; 90 unclassified (40 proposed, 2 derive groups), largest: src" in line
|
||||
line = coverage_line({**base, "proposed": 0, "derive_groups": [{"group": "a"}]})
|
||||
assert "(1 derive group)" in line
|
||||
# Milestone 302: a css top copy says what renders it; unused classes
|
||||
# join the standing block only when measured (None = no evidence).
|
||||
line = coverage_line({**base, "unclassified": 0, "proposed": 0, "derive_groups": [
|
||||
{"group": "name:css:error-msg", "label": ".error-msg", "files": 6,
|
||||
"consumers": {"count": 6, "paths": ["a.vue"]}}], "unused_css": 3})
|
||||
assert "top copy .error-msg ×6 files · used by 6 templates" in line
|
||||
assert "3 unused classes" in line
|
||||
line = coverage_line({**base, "unclassified": 0, "proposed": 0, "derive_groups": [
|
||||
{"group": "name:css:x", "label": ".x", "files": 2,
|
||||
"consumers": {"count": 1, "paths": ["a.vue"]}}], "unused_css": None})
|
||||
assert "top copy .x ×2 files · used by 1 template" in line and "unused" not in line
|
||||
# #2874: the next action on the line — biggest canon queue, widest copy.
|
||||
line = coverage_line({
|
||||
**base, "proposed": 40, "top_canon": {"snippet_id": 2844, "count": 78},
|
||||
@@ -512,6 +623,41 @@ def test_coverage_line_names_the_proposers_standing():
|
||||
assert "top canon #2844 ×78" in line and "top copy closed-msg (identical body) ×3 files" in line
|
||||
|
||||
|
||||
def test_coverage_line_shows_standing_work_even_with_nothing_unclassified():
|
||||
"""#2899: since the scoped bucket a ledger can be fully accounted and
|
||||
still carry derive groups / proposals / divergence — the line names
|
||||
them as `standing:` instead of hiding them behind the todo count, and
|
||||
names the drift since the previous refresh first-copy-first."""
|
||||
from scribe.services.coverage import coverage_line
|
||||
|
||||
base = {
|
||||
"total": 4693, "accounted": 4693, "unclassified": 0,
|
||||
"counts": {"canonical": 37, "instance": 977, "variant": 73, "exempt": 1797, "scoped": 1809},
|
||||
"computed_at": "2026-08-22T00:00:00+00:00", "largest_gaps": [],
|
||||
}
|
||||
quiet = coverage_line(base)
|
||||
assert "unclassified" not in quiet and "standing" not in quiet
|
||||
line = coverage_line({
|
||||
**base,
|
||||
"derive_groups": [{"group": "dup:abc", "label": "log-empty (identical body)", "files": 4}],
|
||||
"derive_new": {"count": 2, "examples": [
|
||||
{"label": ".error-msg", "path": "frontend/src/components/InceptionCard.vue", "group": "dup:9f0"},
|
||||
{"label": ".error-msg", "path": "frontend/src/components/Other.vue", "group": "dup:9f0"},
|
||||
]},
|
||||
"divergent": 1,
|
||||
})
|
||||
assert "; standing: 1 derive group, +2 new copies since last refresh: .error-msg in " \
|
||||
"frontend/src/components/InceptionCard.vue, 1 DIVERGENT, top copy log-empty (identical body) ×4 files" in line
|
||||
assert "unclassified" not in line
|
||||
# One copy reads singular; with a todo the block keeps its old place.
|
||||
one = coverage_line({**base, "derive_new": {"count": 1, "examples": []}})
|
||||
assert one.endswith("; standing: +1 new copy since last refresh")
|
||||
todo = coverage_line({**base, "unclassified": 3, "accounted": 4690, "proposed": 2,
|
||||
"derive_new": {"count": 1, "examples": [{"label": "x", "path": "a.py"}]},
|
||||
"largest_gaps": [{"dir": "src", "unclassified": 3, "total": 9}]})
|
||||
assert "; 3 unclassified (2 proposed, +1 new copy since last refresh: x in a.py), largest: src" in todo
|
||||
|
||||
|
||||
def test_coverage_line_names_divergence_and_recheck():
|
||||
from scribe.services.coverage import coverage_line
|
||||
|
||||
|
||||
@@ -308,6 +308,12 @@ def test_derive_groups_copy_before_name_with_floors():
|
||||
("d.css", "css", "btn", "s1"), ("e.css", "css", "btn", "s2"), ("f.css", "css", "btn", "s3"),
|
||||
("g.py", "sym", "main", "s4"), ("h.py", "sym", "main", "s5"), # only 2 files → no name group
|
||||
("i.py", "sym", "one", "s6"),
|
||||
# CSS (note 2917): identical bodies under different names are NOT a
|
||||
# copy — two meanings sharing the style system's look; the same
|
||||
# class in two files IS a family (the name floor is 2 for css).
|
||||
("j.css", "css", "muted", "same"), ("k.css", "css", "pin-auto", "same"),
|
||||
("l.css", "css", "card", "c1"), ("m.css", "css", "card", "c2"),
|
||||
("n.css", "css", "alone", "c3"),
|
||||
]
|
||||
g = derive_groups(rows)
|
||||
assert g[("a.py", "sym", "helper")] == "dup:sha1" == g[("b.py", "sym", "helper")]
|
||||
@@ -315,6 +321,42 @@ def test_derive_groups_copy_before_name_with_floors():
|
||||
assert g[("d.css", "css", "btn")] == "name:css:btn"
|
||||
assert ("g.py", "sym", "main") not in g
|
||||
assert ("i.py", "sym", "one") not in g
|
||||
assert ("j.css", "css", "muted") not in g and ("k.css", "css", "pin-auto") not in g
|
||||
assert g[("l.css", "css", "card")] == "name:css:card" == g[("m.css", "css", "card")]
|
||||
assert ("n.css", "css", "alone") not in g
|
||||
assert not any(v.startswith("dup:") for k, v in g.items() if k[1] == "css")
|
||||
|
||||
|
||||
def test_derive_new_summary_counts_copies_first_seen_since_the_previous_refresh():
|
||||
"""#2899: the arrival-moment drift signal — derive-grouped rows created
|
||||
after the previous refresh's stamp, newest first, judged rows and a
|
||||
first seed (since=None) never count."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from scribe.models.code_shape import CodeShape
|
||||
from scribe.services.shape_ledger import derive_new_summary
|
||||
|
||||
t0 = datetime(2026, 8, 22, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
def row(path, symbol, at, kind="css", status="scoped", group="dup:abc", basis="derive"):
|
||||
r = CodeShape(project_id=2, repo_key="r", path=path, symbol=symbol, kind=kind,
|
||||
status=status, proposal_basis=basis, proposal_group=group)
|
||||
r.created_at = at
|
||||
return r
|
||||
rows = [
|
||||
row("v/Old.vue", "error-msg", t0 - timedelta(days=3)), # before the stamp
|
||||
row("v/InceptionCard.vue", "error-msg", t0 + timedelta(hours=1)), # new copy
|
||||
row("v/Other.vue", "error-msg", t0 + timedelta(hours=2)), # newer copy
|
||||
row("v/J.vue", "error-msg", t0 + timedelta(hours=3), status="exempt"), # judged: never
|
||||
row("s/a.py", "load", t0 + timedelta(hours=1), kind="sym", group=None, basis=None), # no family
|
||||
]
|
||||
out = derive_new_summary(rows, since=t0)
|
||||
assert out["count"] == 2
|
||||
assert [e["path"] for e in out["examples"]] == ["v/Other.vue", "v/InceptionCard.vue"]
|
||||
assert out["examples"][0] == {"label": ".error-msg", "path": "v/Other.vue", "group": "dup:abc"}
|
||||
assert derive_new_summary(rows, since=None) == {"count": 0, "examples": []}
|
||||
assert derive_new_summary(rows, since=t0, top=1)["examples"] == [
|
||||
{"label": ".error-msg", "path": "v/Other.vue", "group": "dup:abc"}]
|
||||
|
||||
|
||||
def test_proposal_summary_ranks_body_identical_groups_first_and_sees_scoped_rows():
|
||||
@@ -338,6 +380,17 @@ def test_proposal_summary_ranks_body_identical_groups_first_and_sees_scoped_rows
|
||||
row("v/J.vue", "closed-msg", "dup:abc", status="exempt"),
|
||||
]
|
||||
out = proposal_summary(rows)
|
||||
# Milestone 302: with consumer paths in hand, each css group says what
|
||||
# renders it — distinct files across the members; absent otherwise.
|
||||
assert "consumers" not in out["derive_groups"][0]
|
||||
for i, r in enumerate(rows): # unsaved rows have no id; give them one
|
||||
r.id = i + 1
|
||||
cpaths = {rows[0].id: ["v/0.vue", "v/Z.vue"], rows[1].id: ["v/1.vue"], rows[2].id: ["v/0.vue"]}
|
||||
with_c = proposal_summary(rows, consumer_paths=cpaths)
|
||||
badge = next(g for g in with_c["derive_groups"] if g["group"] == "name:css:status-badge")
|
||||
assert badge["consumers"] == {"count": 3, "paths": ["v/0.vue", "v/1.vue", "v/Z.vue"]}
|
||||
dup = next(g for g in with_c["derive_groups"] if g["group"] == "dup:abc")
|
||||
assert dup["consumers"] == {"count": 0, "paths": []}
|
||||
assert [g["group"] for g in out["derive_groups"]] == ["dup:abc", "dup:def", "name:css:status-badge"]
|
||||
assert out["derive_groups"][0]["files"] == 3 and out["derive_groups"][0]["size"] == 3
|
||||
assert out["derive_groups"][0]["label"] == "closed-msg (identical body)"
|
||||
@@ -391,6 +444,44 @@ def test_compact_row_carries_identity_standing_and_the_proposers_word_only():
|
||||
assert noisy not in compact
|
||||
|
||||
|
||||
def test_resolve_consumers_prefers_the_own_file_and_fans_out_for_shared_names():
|
||||
"""Milestone 302: a class named in a template resolves to that file's
|
||||
OWN row when it defines the class (a scoped rule, consumed by its own
|
||||
markup); otherwise to every other definition of the name — one shared
|
||||
sheet, or all of several (the map fans out rather than guessing)."""
|
||||
from scribe.services.shape_ledger import resolve_consumers
|
||||
css_rows = [
|
||||
(1, "v/A.vue", "error-msg"), # scoped, defined + used in A
|
||||
(2, "v/B.vue", "error-msg"), # scoped, defined in B, used in B and C
|
||||
(3, "assets/components.css", "btn-primary"), # the shared sheet
|
||||
(4, "assets/a.css", "pill"), (5, "assets/b.css", "pill"), # two shared defs
|
||||
(6, "assets/c.css", "unused"),
|
||||
]
|
||||
refs = {
|
||||
"v/A.vue": {"error-msg": 2, "btn-primary": 1, "nothing-defined": 1},
|
||||
"v/B.vue": {"error-msg": 1},
|
||||
"v/C.vue": {"error-msg": 1, "pill": 3},
|
||||
}
|
||||
assert resolve_consumers(css_rows, refs) == {
|
||||
(1, "v/A.vue"): 2, # own row, not B's
|
||||
(3, "v/A.vue"): 1, # the shared sheet
|
||||
(2, "v/B.vue"): 1, # own row
|
||||
(1, "v/C.vue"): 1, (2, "v/C.vue"): 1, # C defines none → every other definition
|
||||
(4, "v/C.vue"): 3, (5, "v/C.vue"): 3, # ambiguous: both, not a guess
|
||||
}
|
||||
# Unknown tokens and an unreferenced row leave no trace.
|
||||
assert all(sid != 6 for sid, _ in resolve_consumers(css_rows, refs))
|
||||
|
||||
|
||||
def test_consumer_edges_table_cascades_with_the_shape():
|
||||
from scribe.models import Base
|
||||
from scribe.models.code_shape import CONSUMER_BASES, CodeShapeConsumer
|
||||
assert "code_shape_consumers" in Base.metadata.tables
|
||||
cols = CodeShapeConsumer.__table__.c
|
||||
assert next(iter(cols.shape_id.foreign_keys)).ondelete == "CASCADE"
|
||||
assert CONSUMER_BASES == ("template",)
|
||||
|
||||
|
||||
def test_uses_edges_table_and_validation():
|
||||
"""#2870: consumption is its own relation — a table that cascades with
|
||||
both ends, and `uses` on a classification must be a list of ids."""
|
||||
|
||||
@@ -13,7 +13,7 @@ from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from tests.helpers import fake_note
|
||||
from tests.helpers import fake_note, http_sink
|
||||
|
||||
PLUGIN = Path(__file__).resolve().parents[1] / "plugin"
|
||||
HOOK = PLUGIN / "hooks" / "scribe_prior_art.sh"
|
||||
@@ -853,14 +853,18 @@ def test_hook_exits_silently_when_unconfigured():
|
||||
|
||||
|
||||
def test_hook_skips_prose_and_data_files():
|
||||
"""No round-trip for a markdown edit — the server would return nothing anyway."""
|
||||
src = HOOK.read_text()
|
||||
skip = re.search(r"case \"\$file_path\" in\n(.*?)esac", src, re.S)
|
||||
assert skip, "expected an extension skip list"
|
||||
"""No round-trip for a markdown edit — the server would return nothing
|
||||
anyway. The list lives in the shared library (#2901) and the hook asks it."""
|
||||
lib = (PLUGIN / "hooks" / "scribe_defs.sh").read_text()
|
||||
skip = re.search(r"scribe_skip_path\(\) \{\n case \"\$1\" in\n(.*?)esac", lib, re.S)
|
||||
assert skip, "expected an extension skip list in scribe_defs.sh"
|
||||
for ext in ("*.md", "*.json", "*.lock", "*.png"):
|
||||
assert ext in skip.group(1)
|
||||
# Config formats are deliberately NOT skipped — a workflow file is reusable.
|
||||
assert "*.yml" not in skip.group(1)
|
||||
src = HOOK.read_text()
|
||||
assert 'scribe_skip_path "$file_path" && exit 0' in src
|
||||
assert '/scribe_defs.sh"' in src # sourced, not copied
|
||||
|
||||
|
||||
def test_plugin_version_bumped_with_the_hook():
|
||||
@@ -905,29 +909,35 @@ def _hook_runtime_env():
|
||||
"SCRIBE_URL": "http://127.0.0.1:9", "SCRIBE_TOKEN": "t"}
|
||||
|
||||
|
||||
def test_hook_nudges_recording_when_copies_exist_but_nothing_is_recorded(tmp_path):
|
||||
"""#2664: the local arm proves duplication; when Scribe has no record of it,
|
||||
the same context block must ask for create_snippet — the one moment the
|
||||
recording nudge is earned rather than noise. An unreachable server counts
|
||||
as "nothing recorded": the local finding needed no server, and the nudge
|
||||
fails open with it (here: a refused connection stands in for the instance)."""
|
||||
env = _hook_runtime_env()
|
||||
def _dup_repo(tmp_path, env):
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env)
|
||||
(repo / "a.py").write_text("def debounce(fn):\n return fn\n")
|
||||
# git grep searches the index, so the existing copy must be staged.
|
||||
subprocess.run(["git", "add", "."], cwd=repo, check=True, env=env)
|
||||
out = subprocess.run(
|
||||
["bash", str(HOOK)],
|
||||
input=json.dumps({
|
||||
"session_id": "s-nudge", "cwd": str(repo), "tool_name": "Write",
|
||||
"tool_input": {"file_path": str(repo / "b.py"),
|
||||
"content": "def debounce(fn):\n return fn\n"},
|
||||
}),
|
||||
capture_output=True, text=True, env=env,
|
||||
)
|
||||
return repo
|
||||
|
||||
|
||||
def _write_event(repo, session="s-nudge"):
|
||||
return json.dumps({
|
||||
"session_id": session, "cwd": str(repo), "tool_name": "Write",
|
||||
"tool_input": {"file_path": str(repo / "b.py"),
|
||||
"content": "def debounce(fn):\n return fn\n"},
|
||||
})
|
||||
|
||||
|
||||
def test_hook_nudges_recording_when_copies_exist_but_nothing_is_recorded(tmp_path):
|
||||
"""#2664: the local arm proves duplication; when Scribe ANSWERS that it has
|
||||
no record of it, the same context block must ask for create_snippet — the
|
||||
one moment the recording nudge is earned rather than noise."""
|
||||
with http_sink(b'{"context":"","note_ids":[],"sync_note_ids":[]}') as (port, seen):
|
||||
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{port}")
|
||||
repo = _dup_repo(tmp_path, env)
|
||||
out = subprocess.run(["bash", str(HOOK)], input=_write_event(repo),
|
||||
capture_output=True, text=True, env=env)
|
||||
assert out.returncode == 0
|
||||
assert seen and seen[0]["path"] == ["b.py"]
|
||||
assert out.stdout.strip(), (
|
||||
"hook produced no output — the local arm should have found the "
|
||||
"staged duplicate and nudged"
|
||||
@@ -935,6 +945,51 @@ def test_hook_nudges_recording_when_copies_exist_but_nothing_is_recorded(tmp_pat
|
||||
ctx = json.loads(out.stdout)["hookSpecificOutput"]["additionalContext"]
|
||||
assert "already defined" in ctx # the duplication finding
|
||||
assert "create_snippet" in ctx # the recording ask riding it
|
||||
assert "did not answer" not in ctx
|
||||
|
||||
|
||||
def test_hook_says_when_scribe_did_not_answer_once_per_outage(tmp_path):
|
||||
"""#2932: a configured instance that does not answer (refused connection)
|
||||
is SAID — the write went unchecked — instead of the hook failing open in
|
||||
silence; the record nudge's "nothing recorded" claim is withheld. Once per
|
||||
outage: a second miss is quiet, an answer clears the marker, and the next
|
||||
miss speaks again. The marker is shared with the after-write hook."""
|
||||
env = _hook_runtime_env() # SCRIBE_URL → a refused port
|
||||
repo = _dup_repo(tmp_path, env)
|
||||
marker = tmp_path / "scribe-priorart" / "s-out.unreached"
|
||||
env["TMPDIR"] = str(tmp_path)
|
||||
out = subprocess.run(["bash", str(HOOK)], input=_write_event(repo, "s-out"),
|
||||
capture_output=True, text=True, env=env)
|
||||
assert out.returncode == 0
|
||||
ctx = json.loads(out.stdout)["hookSpecificOutput"]["additionalContext"]
|
||||
assert "already defined" in ctx
|
||||
assert "Scribe did not answer the prior-art check for `b.py` within 5s" in ctx
|
||||
assert "UNCHECKED" in ctx and "list_shapes" in ctx
|
||||
assert "None of those existing copies is recorded" not in ctx
|
||||
assert marker.is_file()
|
||||
# Second miss inside the quiet window: local arm only.
|
||||
out = subprocess.run(["bash", str(HOOK)], input=_write_event(repo, "s-out"),
|
||||
capture_output=True, text=True, env=env)
|
||||
ctx = json.loads(out.stdout)["hookSpecificOutput"]["additionalContext"]
|
||||
assert "already defined" in ctx and "did not answer" not in ctx
|
||||
# An answer clears the marker …
|
||||
with http_sink(b'{"context":"","note_ids":[],"sync_note_ids":[]}') as (port, _seen):
|
||||
up = dict(env, SCRIBE_URL=f"http://127.0.0.1:{port}")
|
||||
subprocess.run(["bash", str(HOOK)], input=_write_event(repo, "s-out"),
|
||||
capture_output=True, text=True, env=up)
|
||||
assert not marker.exists()
|
||||
# … so the next outage is announced afresh.
|
||||
out = subprocess.run(["bash", str(HOOK)], input=_write_event(repo, "s-out"),
|
||||
capture_output=True, text=True, env=env)
|
||||
assert "did not answer" in json.loads(out.stdout)["hookSpecificOutput"]["additionalContext"]
|
||||
# A write the hook had nothing local to say about still carries the line
|
||||
# (the line is the whole message then): a fresh session, no duplicate.
|
||||
(repo / "a.py").unlink()
|
||||
subprocess.run(["git", "add", "-A"], cwd=repo, check=True, env=env)
|
||||
out = subprocess.run(["bash", str(HOOK)], input=_write_event(repo, "s-out-2"),
|
||||
capture_output=True, text=True, env=env)
|
||||
ctx = json.loads(out.stdout)["hookSpecificOutput"]["additionalContext"]
|
||||
assert ctx.startswith("> Scribe did not answer")
|
||||
|
||||
|
||||
def test_hook_stays_quiet_about_recording_when_nothing_is_duplicated(tmp_path):
|
||||
@@ -978,24 +1033,26 @@ def test_local_arm_finds_duplicates_in_every_language_family(
|
||||
every Go/Kotlin/Rust project, which is exactly where the operator observed
|
||||
recording never happening. Each case stages an existing copy and writes the
|
||||
same definition to a second file; the hook must prove the duplication and
|
||||
ask for the record."""
|
||||
env = _hook_runtime_env()
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env)
|
||||
(repo / fname).write_text(definition)
|
||||
subprocess.run(["git", "add", "."], cwd=repo, check=True, env=env)
|
||||
ext = fname.rsplit(".", 1)[1]
|
||||
out = subprocess.run(
|
||||
["bash", str(HOOK)],
|
||||
input=json.dumps({
|
||||
"session_id": f"s-lang-{ext}", "cwd": str(repo),
|
||||
"tool_name": "Write",
|
||||
"tool_input": {"file_path": str(repo / f"copy.{ext}"),
|
||||
"content": definition},
|
||||
}),
|
||||
capture_output=True, text=True, env=env,
|
||||
)
|
||||
ask for the record (the instance ANSWERS "nothing recorded" — since #2932
|
||||
an unanswered call withholds the nudge, so a sink stands in for it)."""
|
||||
with http_sink(b'{"context":"","note_ids":[],"sync_note_ids":[]}') as (port, _seen):
|
||||
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{port}")
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env)
|
||||
(repo / fname).write_text(definition)
|
||||
subprocess.run(["git", "add", "."], cwd=repo, check=True, env=env)
|
||||
ext = fname.rsplit(".", 1)[1]
|
||||
out = subprocess.run(
|
||||
["bash", str(HOOK)],
|
||||
input=json.dumps({
|
||||
"session_id": f"s-lang-{ext}", "cwd": str(repo),
|
||||
"tool_name": "Write",
|
||||
"tool_input": {"file_path": str(repo / f"copy.{ext}"),
|
||||
"content": definition},
|
||||
}),
|
||||
capture_output=True, text=True, env=env,
|
||||
)
|
||||
assert out.returncode == 0
|
||||
assert out.stdout.strip(), (
|
||||
f"hook produced no output for {fname} — the local arm should have "
|
||||
@@ -1145,12 +1202,31 @@ def test_route_stamps_only_for_a_caller_allowed_to_write():
|
||||
assert "&shapes=" in hook
|
||||
|
||||
|
||||
def test_hook_does_not_name_a_type_import_specifier_as_a_shape(tmp_path):
|
||||
"""#2904, the awk mirror of the server rule: `type Foo,` inside an import
|
||||
list is not a definition; `type Baz = …` on its own line is."""
|
||||
env = _hook_runtime_env()
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env)
|
||||
seen = _run_hook_against_sink(tmp_path, {
|
||||
"session_id": "s-type", "cwd": str(repo), "tool_name": "Write",
|
||||
"tool_input": {"file_path": str(repo / "x.ts"),
|
||||
"content": 'import { type Foo, bar } from "./y";\n'
|
||||
"type Baz = { a: number };\n"
|
||||
"export function use(): Baz {\n return { a: 1 };\n}\n"},
|
||||
})
|
||||
assert seen["shapes"] == ["sym:Baz,sym:use"]
|
||||
|
||||
|
||||
def test_hook_names_the_shapes_being_written():
|
||||
"""The feed's two inputs: every definition in the payload, or — for an Edit
|
||||
that changes a body, not a signature — the definition enclosing the edit,
|
||||
found by walking the target file upward from the edited lines."""
|
||||
src = HOOK.read_text()
|
||||
assert "scribe_defs()" in src # one extractor, two consumers
|
||||
lib = (PLUGIN / "hooks" / "scribe_defs.sh").read_text()
|
||||
assert "scribe_defs()" in lib # one extractor, shared (#2901)
|
||||
assert "scribe_defs()" not in src # ...not a second copy here
|
||||
assert ".tool_input.old_string" in src # the Edit's anchor
|
||||
assert "| tac | scribe_defs | head -1" in src # nearest definition above
|
||||
# The ledger feed sends NAMES, never bodies, and stays on the one GET.
|
||||
@@ -1159,39 +1235,15 @@ def test_hook_names_the_shapes_being_written():
|
||||
|
||||
def _run_hook_against_sink(tmp_path, payload):
|
||||
"""Run the hook with SCRIBE_URL pointed at a throwaway local listener and
|
||||
return the query the hook sent. Lets the shell be tested end to end —
|
||||
the extraction, the encoding, the URL — without a Scribe instance."""
|
||||
import http.server
|
||||
import threading
|
||||
import urllib.parse
|
||||
|
||||
seen: dict = {}
|
||||
|
||||
class _Sink(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
seen.update(urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query))
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"context":"","note_ids":[]}')
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
server = http.server.HTTPServer(("127.0.0.1", 0), _Sink)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{server.server_port}")
|
||||
return the query the hook sent (tests.helpers.http_sink)."""
|
||||
with http_sink() as (port, seen):
|
||||
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{port}")
|
||||
out = subprocess.run(
|
||||
["bash", str(HOOK)], input=json.dumps(payload),
|
||||
capture_output=True, text=True, env=env,
|
||||
)
|
||||
assert out.returncode == 0, out.stderr
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
return seen
|
||||
return seen[0] if seen else {}
|
||||
|
||||
|
||||
def test_hook_sends_every_definition_in_a_write(tmp_path):
|
||||
@@ -1243,6 +1295,109 @@ def test_hook_sends_the_enclosing_definition_for_a_body_edit(tmp_path):
|
||||
assert seen["shapes"] == ["sym:onTrash"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_write_time_derive_check_names_a_family_or_a_canon_in_band():
|
||||
"""#2900: the ledger's own word on the names being written — a duplicate
|
||||
family with no canon, or a canon recorded elsewhere — rendered at the
|
||||
write even when nothing else does; keyed so the session's exclude
|
||||
channel silences a family already named."""
|
||||
from scribe.services import plugin_context as pc
|
||||
found = [
|
||||
{"symbol": "log-empty", "kind": "css", "key": "name:css:log-empty",
|
||||
"family": {"group": "name:css:log-empty", "label": ".log-empty", "identical": False,
|
||||
"files": ["a/TaskLogSection.vue", "a/WorkspaceTaskPanel.vue"],
|
||||
"file_count": 5, "size": 6,
|
||||
"consumers": {"count": 6, "paths": ["a/TaskLogSection.vue", "a/V.vue"]}}},
|
||||
{"symbol": "btn-primary", "kind": "css", "key": "canon:2855",
|
||||
"canon": {"snippet_id": 2855, "path": "frontend/src/assets/components.css",
|
||||
"label": ".btn-primary"}},
|
||||
{"symbol": "slugify", "kind": "sym", "key": "dup:483a",
|
||||
"family": {"group": "dup:483a", "label": "slugify", "identical": True,
|
||||
"files": ["a/x.py", "a/y.py"], "file_count": 2, "size": 3}},
|
||||
{"symbol": "load", "kind": "sym", "key": "name:sym:load",
|
||||
"family": {"group": "name:sym:load", "label": "load", "identical": False,
|
||||
"files": ["a/X.vue", "a/Y.vue", "a/Z.vue"], "file_count": 3, "size": 4}},
|
||||
]
|
||||
check = AsyncMock(return_value=found)
|
||||
patches = dict(
|
||||
get_writepath_config=AsyncMock(return_value=_cfg()),
|
||||
semantic_search_notes=AsyncMock(return_value=[]),
|
||||
record_retrieval=MagicMock(), owner_names_for=AsyncMock(return_value={}),
|
||||
)
|
||||
with patch.multiple(pc, **patches), \
|
||||
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \
|
||||
patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={})), \
|
||||
patch.object(pc.shape_ledger_svc, "write_time_divergence", AsyncMock(return_value=[])), \
|
||||
patch.object(pc.shape_ledger_svc, "write_time_derive", check):
|
||||
out = await pc.build_write_path_hint(
|
||||
1, "frontend/src/components/New.vue", code=REAL_CODE, project_id=24,
|
||||
stamp_shapes=[("css", "log-empty"), ("css", "btn-primary"), ("sym", "slugify"),
|
||||
("sym", "load")],
|
||||
exclude_derive=["name:sym:load"],
|
||||
)
|
||||
check.assert_awaited_once_with(24, "frontend/src/components/New.vue",
|
||||
[("css", "log-empty"), ("css", "btn-primary"),
|
||||
("sym", "slugify"), ("sym", "load")])
|
||||
# The excluded family is gone; the other three render and are keyed.
|
||||
assert [d["key"] for d in out["derive"]] == ["name:css:log-empty", "canon:2855", "dup:483a"]
|
||||
assert out["derive_keys"] == ["name:css:log-empty", "canon:2855", "dup:483a"]
|
||||
ctx = out["context"]
|
||||
assert "Shape ledger at `frontend/src/components/New.vue`" in ctx
|
||||
# A CSS family is a repeated NAME (note 2917) and its dismissal is scoped-css;
|
||||
# a code dup family is an identical body and dismisses as convention-plumbing.
|
||||
# A css family says what renders it (milestone 302) before the ask.
|
||||
assert "`.log-empty` is a repeated name with no canon — defined in 5 other file(s): " \
|
||||
"`a/TaskLogSection.vue`, `a/WorkspaceTaskPanel.vue` +3 more; used by 6 templates: " \
|
||||
"`a/TaskLogSection.vue`, `a/V.vue` +4 more; derive it now" in ctx
|
||||
assert "`slugify` is a duplicate family with no canon — identical body in 2 other file(s): " \
|
||||
"`a/x.py`, `a/y.py`; derive it now" in ctx
|
||||
assert "`.btn-primary` is canon — snippet #2855 at `frontend/src/assets/components.css`" in ctx
|
||||
assert 'reason_code=\"scoped-css\")` dismisses' in ctx
|
||||
assert 'reason_code=\"convention-plumbing\")` dismisses' in ctx
|
||||
assert "`load`" not in ctx
|
||||
# No project → no check; a failing check never sinks the hint.
|
||||
check.reset_mock()
|
||||
with patch.multiple(pc, **patches), \
|
||||
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \
|
||||
patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={})), \
|
||||
patch.object(pc.shape_ledger_svc, "write_time_divergence", AsyncMock(return_value=[])), \
|
||||
patch.object(pc.shape_ledger_svc, "write_time_derive", check):
|
||||
out = await pc.build_write_path_hint(1, "x.py", code=REAL_CODE, stamp_shapes=[("sym", "f")])
|
||||
check.assert_not_awaited()
|
||||
assert out["derive"] == [] and out["derive_keys"] == []
|
||||
boom = AsyncMock(side_effect=RuntimeError("ledger down"))
|
||||
with patch.multiple(pc, **patches), \
|
||||
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \
|
||||
patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={})), \
|
||||
patch.object(pc.shape_ledger_svc, "write_time_divergence", AsyncMock(return_value=[])), \
|
||||
patch.object(pc.shape_ledger_svc, "write_time_derive", boom):
|
||||
out = await pc.build_write_path_hint(1, "x.py", code=REAL_CODE, project_id=24,
|
||||
stamp_shapes=[("sym", "f")])
|
||||
assert out["context"] == "" and out["derive"] == []
|
||||
|
||||
|
||||
def test_the_hook_keeps_a_derive_channel_and_sends_it_back(tmp_path):
|
||||
"""#2900: derive keys the server returns land in the session's own
|
||||
`.derive.ids` file and go back as `exclude_derive` on the next write —
|
||||
a family is named once per session, not at every edit."""
|
||||
reply = (b'{"context":"> family","note_ids":[],"sync_note_ids":[],'
|
||||
b'"derive_keys":["dup:483a","canon:2855"]}')
|
||||
with http_sink(reply) as (port, seen):
|
||||
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{port}",
|
||||
TMPDIR=str(tmp_path))
|
||||
payload = {"session_id": "s-derive-1", "cwd": str(tmp_path), "tool_name": "Write",
|
||||
"tool_input": {"file_path": str(tmp_path / "a.css"),
|
||||
"content": ".log-empty {\n color: red;\n}\n"}}
|
||||
for _ in range(2):
|
||||
out = subprocess.run(["bash", str(HOOK)], input=json.dumps(payload),
|
||||
capture_output=True, text=True, env=env)
|
||||
assert out.returncode == 0, out.stderr
|
||||
assert "exclude_derive" not in seen[0]
|
||||
assert seen[1]["exclude_derive"] == ["dup:483a,canon:2855"]
|
||||
state = tmp_path / "scribe-priorart" / "s-derive-1.derive.ids"
|
||||
assert state.read_text().split() == ["dup:483a", "canon:2855", "dup:483a", "canon:2855"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_write_time_divergence_check_is_named_in_band():
|
||||
"""#2793: the hook named a shape at a path whose directory a canon
|
||||
|
||||
Reference in New Issue
Block a user