diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index fc4986c..ad6a9d6 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -165,7 +165,18 @@ jobs: # ruff is pre-installed in the ci-python image — no install # step needed, lint runs in ~2s. - name: Lint - run: ruff check src/ + run: ruff check src/ scripts/ + + # Design tokens: does the frontend's CSS agree with the stylesheet the + # design system generates? Fails only on an unresolvable var() reference — + # that count is at zero, so this is a ratchet rather than a backlog. The + # literal findings are printed, not gated; hundreds exist and a + # permanently-red job is one nobody reads. + # + # Stdlib only, no install, no network: the source of truth is theme.css, + # which is generated from the design system and committed. + - name: Design token check + run: python3 scripts/check_design_tokens.py --report-literals test: name: Python tests diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d84293e..25b887f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -255,7 +255,7 @@ onUnmounted(() => { z-index: 9999; padding: 0.4rem 0.75rem; background: var(--color-primary); - color: #fff; + color: var(--fs-text-on-action); border-radius: 0 0 4px 4px; font-size: 0.875rem; text-decoration: none; diff --git a/frontend/src/assets/editor-shared.css b/frontend/src/assets/editor-shared.css index bfff033..dcd044a 100644 --- a/frontend/src/assets/editor-shared.css +++ b/frontend/src/assets/editor-shared.css @@ -56,7 +56,7 @@ .btn-save { padding: 0.45rem 1.1rem; background: var(--color-action-primary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; @@ -76,7 +76,7 @@ .btn-delete { padding: 0.45rem 1rem; background: var(--color-action-destructive); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; @@ -187,12 +187,12 @@ } .tag-pill:hover:not(:disabled) { background: var(--color-primary); - color: #fff; + color: var(--fs-text-on-action); } .tag-pill.applied { background: var(--color-success, #2ecc71); border-color: var(--color-success, #2ecc71); - color: #fff; + color: var(--fs-text-on-action); cursor: default; } .tag-check { @@ -345,7 +345,7 @@ .btn-generate { padding: 0.4rem 0.9rem; background: var(--color-primary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; @@ -478,7 +478,7 @@ .btn-accept { padding: 0.4rem 1rem; background: var(--color-success); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; @@ -537,7 +537,7 @@ } .modal-btn-danger { background: var(--color-danger); - color: #fff; + color: var(--fs-text-on-action); border-color: var(--color-danger); } @@ -548,7 +548,7 @@ transform: translateX(-50%); padding: 0.3rem 0.75rem; background: var(--color-primary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; diff --git a/frontend/src/assets/theme.css b/frontend/src/assets/theme.css index 1b26bfb..2b65328 100644 --- a/frontend/src/assets/theme.css +++ b/frontend/src/assets/theme.css @@ -132,9 +132,10 @@ --fs-table-stripe: color-mix(in srgb, var(--fs-text-primary) 3%, transparent); /* text */ - --fs-text-primary: #E8E4D8; /* also the text colour on filled action buttons */ + --fs-text-primary: #E8E4D8; /* body, headings, labels — inverts by mode */ --fs-text-secondary: #C2BFB4; --fs-text-tertiary: #9C9A92; + --fs-text-on-action: #E8E4D8; /* text on a filled colour — NOT mode-dependent */ /* type */ --fs-size-display: 40px; @@ -178,6 +179,14 @@ --fs-text-tertiary: #9A9890; } +/* SUPERSEDES — write the token, not the literal. + * #fff -> --fs-text-on-action + * #ffffff -> --fs-text-on-action + * white -> --fs-text-on-action + * bold -> --fs-weight-medium + * bolder -> --fs-weight-medium + */ + /* ========================================================================== COMPATIBILITY ALIASES — the app's historical names, pointing at the system. @@ -282,7 +291,10 @@ visual shifts exactly where a fallback had drifted; that shift IS the fix. ------------------------------------------------------------------ */ --color-accent: var(--fs-accent); - --color-accent-fg: var(--fs-text-primary); + /* Foreground ON the accent, so it follows the accent's mode-independence, + not the page text's. Pointing this at --fs-text-primary made it invert to + obsidian on light — over a mid-tone accent, well under the AA floor. */ + --color-accent-fg: var(--fs-text-on-action); --color-hover: var(--fs-surface-hover); --color-bg-hover: var(--fs-surface-hover); --color-bg-tertiary: var(--fs-surface-hover); diff --git a/frontend/src/components/HistoryPanel.vue b/frontend/src/components/HistoryPanel.vue index 5e4cd21..c91a9c8 100644 --- a/frontend/src/components/HistoryPanel.vue +++ b/frontend/src/components/HistoryPanel.vue @@ -396,7 +396,7 @@ onMounted(loadVersions); .btn-restore { padding: 0.45rem 1rem; background: var(--color-primary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; diff --git a/frontend/src/components/InlineAssistPanel.vue b/frontend/src/components/InlineAssistPanel.vue index 4fcaffc..28a7340 100644 --- a/frontend/src/components/InlineAssistPanel.vue +++ b/frontend/src/components/InlineAssistPanel.vue @@ -192,7 +192,7 @@ const markers: Record = { } .iap-btn-accept { background: var(--color-success, #22c55e); - color: #fff; + color: var(--fs-text-on-action); } .iap-btn-accept:hover { opacity: 0.85; } diff --git a/frontend/src/components/NotificationBell.vue b/frontend/src/components/NotificationBell.vue index 7f4f25d..d24c659 100644 --- a/frontend/src/components/NotificationBell.vue +++ b/frontend/src/components/NotificationBell.vue @@ -82,7 +82,7 @@ onUnmounted(() => { top: -5px; right: -5px; background: var(--color-danger, #ef4444); - color: #fff; + color: var(--fs-text-on-action); font-size: 0.6rem; font-weight: 700; min-width: 16px; diff --git a/frontend/src/components/PaginationBar.vue b/frontend/src/components/PaginationBar.vue index 05145d9..f55f702 100644 --- a/frontend/src/components/PaginationBar.vue +++ b/frontend/src/components/PaginationBar.vue @@ -90,7 +90,7 @@ function goToPage(page: number) { } .page-btn.active { background: var(--color-primary); - color: #fff; + color: var(--fs-text-on-action); border-color: var(--color-primary); } .ellipsis { diff --git a/frontend/src/components/ShareDialog.vue b/frontend/src/components/ShareDialog.vue index 305f8b9..f68d8b8 100644 --- a/frontend/src/components/ShareDialog.vue +++ b/frontend/src/components/ShareDialog.vue @@ -268,7 +268,7 @@ onMounted(async () => { .share-tab.active { background: var(--color-primary); border-color: var(--color-primary); - color: #fff; + color: var(--fs-text-on-action); } .share-target-form { @@ -339,7 +339,7 @@ onMounted(async () => { .btn-add-share { padding: 0.45rem 1rem; background: var(--gradient-cta); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: 6px; font-size: 0.85rem; diff --git a/frontend/src/components/SystemsSection.vue b/frontend/src/components/SystemsSection.vue index 46ebb2a..cb9d63c 100644 --- a/frontend/src/components/SystemsSection.vue +++ b/frontend/src/components/SystemsSection.vue @@ -390,7 +390,7 @@ async function confirmDelete() { .btn-confirm { padding: 0.35rem 0.8rem; background: var(--color-action-primary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; @@ -405,7 +405,7 @@ async function confirmDelete() { padding: 0.35rem 0.8rem; background: var(--color-action-secondary); border: none; - color: #fff; + color: var(--fs-text-on-action); border-radius: var(--radius-sm); cursor: pointer; font-size: 0.82rem; @@ -555,6 +555,6 @@ async function confirmDelete() { font-family: inherit; } .modal-btn:hover { background: var(--color-bg); } -.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: #fff; } +.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: var(--fs-text-on-action); } .modal-btn-danger:hover { background: var(--color-action-destructive-hover); border-color: var(--color-action-destructive-hover); } diff --git a/frontend/src/components/TaskLogSection.vue b/frontend/src/components/TaskLogSection.vue index a0eed59..3f734e0 100644 --- a/frontend/src/components/TaskLogSection.vue +++ b/frontend/src/components/TaskLogSection.vue @@ -217,7 +217,7 @@ onMounted(loadLogs); .log-duration-badge { background: var(--color-primary); - color: #fff; + color: var(--fs-text-on-action); border-radius: 99px; padding: 0.1rem 0.5rem; font-size: 0.72rem; @@ -312,7 +312,7 @@ onMounted(loadLogs); margin-left: auto; padding: 0.3rem 0.75rem; background: var(--color-primary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; diff --git a/frontend/src/components/ToastNotification.vue b/frontend/src/components/ToastNotification.vue index 341cd05..b82e02b 100644 --- a/frontend/src/components/ToastNotification.vue +++ b/frontend/src/components/ToastNotification.vue @@ -36,7 +36,7 @@ const toastStore = useToastStore(); gap: 0.5rem; padding: 0.75rem 1rem; border-radius: 6px; - color: #fff; + color: var(--fs-text-on-action); font-size: 0.9rem; box-shadow: 0 2px 8px var(--color-shadow); min-width: 200px; @@ -54,7 +54,7 @@ const toastStore = useToastStore(); padding: 0 0.15rem; } .toast-close:hover { - color: #fff; + color: var(--fs-text-on-action); } .toast--success { background: var(--color-toast-success); diff --git a/frontend/src/components/VersionHistorySection.vue b/frontend/src/components/VersionHistorySection.vue index 1faf4fd..6113d25 100644 --- a/frontend/src/components/VersionHistorySection.vue +++ b/frontend/src/components/VersionHistorySection.vue @@ -241,7 +241,7 @@ function restore() { border-radius: var(--radius-sm); padding: 0.25rem 0.6rem; font-size: 0.78rem; - color: #fff; + color: var(--fs-text-on-action); cursor: pointer; font-family: inherit; } diff --git a/frontend/src/components/WorkspaceNoteEditor.vue b/frontend/src/components/WorkspaceNoteEditor.vue index 5ad88f5..975c71e 100644 --- a/frontend/src/components/WorkspaceNoteEditor.vue +++ b/frontend/src/components/WorkspaceNoteEditor.vue @@ -643,7 +643,7 @@ defineExpose({ reload: loadProjectNotes }); border-radius: 3px; transition: background 0.15s, color 0.15s; } -.btn-confirm-delete:hover:not(:disabled) { background: var(--color-action-destructive); color: #fff; } +.btn-confirm-delete:hover:not(:disabled) { background: var(--color-action-destructive); color: var(--fs-text-on-action); } .btn-confirm-delete:disabled { opacity: 0.5; cursor: default; } .btn-cancel-delete { @@ -671,7 +671,7 @@ defineExpose({ reload: loadProjectNotes }); .btn-confirm { background: var(--color-primary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: 4px; padding: 0.15rem 0.35rem; @@ -733,7 +733,7 @@ defineExpose({ reload: loadProjectNotes }); /* Moss action-primary per Hybrid */ .btn-save { background: var(--color-action-primary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: 5px; padding: 0.25rem 0.7rem; diff --git a/frontend/src/components/WorkspaceTaskPanel.vue b/frontend/src/components/WorkspaceTaskPanel.vue index 8634ee8..9504478 100644 --- a/frontend/src/components/WorkspaceTaskPanel.vue +++ b/frontend/src/components/WorkspaceTaskPanel.vue @@ -398,7 +398,7 @@ defineExpose({ reload: loadAll }); .btn-add { background: var(--color-primary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: 5px; padding: 0.28rem 0.55rem; @@ -590,7 +590,7 @@ defineExpose({ reload: loadAll }); margin-left: 0.25rem; transition: background 0.15s, color 0.15s; } -.btn-delete-confirm:hover:not(:disabled) { background: var(--color-action-destructive); color: #fff; } +.btn-delete-confirm:hover:not(:disabled) { background: var(--color-action-destructive); color: var(--fs-text-on-action); } .btn-delete-confirm:disabled { opacity: 0.5; cursor: default; } .btn-delete-cancel { diff --git a/frontend/src/views/DesignSystemsView.vue b/frontend/src/views/DesignSystemsView.vue index a11b09f..76fd9f8 100644 --- a/frontend/src/views/DesignSystemsView.vue +++ b/frontend/src/views/DesignSystemsView.vue @@ -1273,14 +1273,9 @@ textarea.input { border: 1px solid transparent; } -/* Background matches the house convention (--color-action-primary, as in - ProjectListView and four others). The text colour deliberately does NOT: - every existing copy uses `color: #fff`, which is 67 live violations of the - rule that pure white is never text (#2275). Parchment is what the rulebook - actually specifies. */ .btn-primary { background: var(--color-action-primary); - color: #E8E4D8; + color: var(--fs-text-on-action); } .btn-primary:not(:disabled):hover { diff --git a/frontend/src/views/ForgotPasswordView.vue b/frontend/src/views/ForgotPasswordView.vue index 2a69590..e274c38 100644 --- a/frontend/src/views/ForgotPasswordView.vue +++ b/frontend/src/views/ForgotPasswordView.vue @@ -141,7 +141,7 @@ async function handleSubmit() { width: 100%; padding: 0.6rem; background: var(--color-primary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; diff --git a/frontend/src/views/KnowledgeView.vue b/frontend/src/views/KnowledgeView.vue index 7d5d54d..11b37aa 100644 --- a/frontend/src/views/KnowledgeView.vue +++ b/frontend/src/views/KnowledgeView.vue @@ -574,7 +574,7 @@ onUnmounted(() => { border-radius: 10px; border: none; background: var(--gradient-cta); - color: #fff; + color: var(--fs-text-on-action); cursor: pointer; font-size: 0.85rem; font-weight: 500; diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 0b06bba..4098d42 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -180,7 +180,7 @@ function loginWithOAuth() { width: 100%; padding: 0.6rem; background: var(--color-primary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; diff --git a/frontend/src/views/NoteEditorView.vue b/frontend/src/views/NoteEditorView.vue index f439afa..8dd5890 100644 --- a/frontend/src/views/NoteEditorView.vue +++ b/frontend/src/views/NoteEditorView.vue @@ -748,7 +748,7 @@ onUnmounted(() => assist.clearSelection()); cursor: pointer; font-family: inherit; } -.btn-link-all:hover { background: var(--color-primary); color: #fff; } +.btn-link-all:hover { background: var(--color-primary); color: var(--fs-text-on-action); } .link-suggest-list { display: flex; diff --git a/frontend/src/views/NoteViewerView.vue b/frontend/src/views/NoteViewerView.vue index ab9e47f..f12200d 100644 --- a/frontend/src/views/NoteViewerView.vue +++ b/frontend/src/views/NoteViewerView.vue @@ -354,7 +354,7 @@ async function convertToTask() { border: none; border-radius: var(--radius-sm); background: var(--color-action-primary); - color: #fff; + color: var(--fs-text-on-action); text-decoration: none; cursor: pointer; font-size: 0.875rem; @@ -363,14 +363,14 @@ async function convertToTask() { } .btn-edit:hover { background: var(--color-action-primary-hover); - color: #fff; + color: var(--fs-text-on-action); } /* Convert + Share: Bronze action-secondary — alternate paths */ .btn-convert { margin-left: auto; padding: 0.3rem 0.75rem; background: var(--color-action-secondary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; @@ -388,7 +388,7 @@ async function convertToTask() { background: var(--color-action-secondary); border: none; border-radius: var(--radius-sm); - color: #fff; + color: var(--fs-text-on-action); cursor: pointer; font-size: 0.85rem; font-family: inherit; diff --git a/frontend/src/views/ProjectListView.vue b/frontend/src/views/ProjectListView.vue index 4c38dfe..f4f3d8a 100644 --- a/frontend/src/views/ProjectListView.vue +++ b/frontend/src/views/ProjectListView.vue @@ -287,7 +287,7 @@ function overallPct(project: Project): { total: number; pct: number } { .btn-primary { padding: 0.45rem 1rem; background: var(--color-action-primary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; @@ -341,7 +341,7 @@ function overallPct(project: Project): { total: number; pct: number } { .empty-title { font-size: 1rem; font-weight: 500; color: var(--color-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(--color-primary); border-radius: var(--radius-sm); color: var(--color-primary); background: none; cursor: pointer; font-size: 0.85rem; transition: background 0.15s, color 0.15s; } -.empty-action:hover { background: var(--color-primary); color: #fff; } +.empty-action:hover { background: var(--color-primary); color: var(--fs-text-on-action); } .skeleton-card { height: 140px; @@ -594,7 +594,7 @@ function overallPct(project: Project): { total: number; pct: number } { .modal-btn-primary { background: var(--color-primary); border-color: var(--color-primary); - color: #fff; + color: var(--fs-text-on-action); } .modal-btn-primary:hover:not(:disabled) { opacity: 0.9; diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue index 14aeca2..9785df1 100644 --- a/frontend/src/views/ProjectView.vue +++ b/frontend/src/views/ProjectView.vue @@ -879,7 +879,7 @@ async function confirmDelete() { gap: 0.35rem; padding: 0.45rem 1rem; background: var(--gradient-cta); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); font-size: 0.875rem; @@ -888,14 +888,14 @@ async function confirmDelete() { box-shadow: var(--glow-cta); transition: box-shadow 0.15s, opacity 0.15s; } -.btn-workspace:hover { box-shadow: var(--glow-cta-hover); opacity: 0.95; color: #fff; } +.btn-workspace:hover { box-shadow: var(--glow-cta-hover); opacity: 0.95; color: var(--fs-text-on-action); } /* Share: Bronze action-secondary — alternate path */ .btn-share { padding: 0.4rem 0.8rem; background: var(--color-action-secondary); border: none; - color: #fff; + color: var(--fs-text-on-action); border-radius: var(--radius-sm); cursor: pointer; font-size: 0.85rem; @@ -917,7 +917,7 @@ async function confirmDelete() { font-family: inherit; transition: background 0.15s, color 0.15s; } -.btn-danger-outline:hover { background: var(--color-action-destructive); color: #fff; } +.btn-danger-outline:hover { background: var(--color-action-destructive); color: var(--fs-text-on-action); } .error-msg { color: var(--color-danger); font-size: 0.9rem; } @@ -1065,7 +1065,7 @@ async function confirmDelete() { .btn-save-panel { padding: 0.45rem 0.9rem; background: var(--color-action-primary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; @@ -1151,7 +1151,7 @@ async function confirmDelete() { .btn-ms-confirm { padding: 0.3rem 0.65rem; background: var(--color-action-primary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; @@ -1165,7 +1165,7 @@ async function confirmDelete() { padding: 0.3rem 0.65rem; background: var(--color-action-secondary); border: none; - color: #fff; + color: var(--fs-text-on-action); border-radius: var(--radius-sm); cursor: pointer; font-size: 0.78rem; @@ -1224,7 +1224,7 @@ async function confirmDelete() { cursor: pointer; border: 1px solid var(--color-border); } -.ms-plan-actions .btn-primary { background: var(--color-primary); color: #fff; border-color: var(--color-primary); } +.ms-plan-actions .btn-primary { background: var(--color-primary); color: var(--fs-text-on-action); border-color: var(--color-primary); } .ms-plan-actions .btn-primary:disabled { opacity: 0.6; cursor: default; } .ms-plan-actions .btn-secondary { background: var(--color-bg-card); color: var(--color-text); } @@ -1393,8 +1393,8 @@ async function confirmDelete() { line-height: 1; } .task-card:hover .task-advance-btn { opacity: 1; } -.task-advance-btn:hover { background: var(--color-primary); border-color: var(--color-primary); color: #fff; } -.task-advance-btn--done:hover { background: var(--color-success, #22c55e); border-color: var(--color-success, #22c55e); color: #fff; } +.task-advance-btn:hover { background: var(--color-primary); border-color: var(--color-primary); color: var(--fs-text-on-action); } +.task-advance-btn--done:hover { background: var(--color-success, #22c55e); border-color: var(--color-success, #22c55e); color: var(--fs-text-on-action); } .task-advance-btn:disabled { opacity: 0.4; cursor: default; } .priority-dot { @@ -1475,7 +1475,7 @@ async function confirmDelete() { font-family: inherit; } .modal-btn:hover { background: var(--color-bg); } -.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: #fff; } +.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: var(--fs-text-on-action); } .modal-btn-danger:hover { background: var(--color-action-destructive-hover); border-color: var(--color-action-destructive-hover); } /* ── Skeleton ────────────────────────────────────────────────── */ diff --git a/frontend/src/views/RegisterInviteView.vue b/frontend/src/views/RegisterInviteView.vue index 6ee9eed..de7d162 100644 --- a/frontend/src/views/RegisterInviteView.vue +++ b/frontend/src/views/RegisterInviteView.vue @@ -251,7 +251,7 @@ async function handleSubmit() { width: 100%; padding: 0.6rem; background: var(--color-primary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; diff --git a/frontend/src/views/RegisterView.vue b/frontend/src/views/RegisterView.vue index 3d71ebe..24112a4 100644 --- a/frontend/src/views/RegisterView.vue +++ b/frontend/src/views/RegisterView.vue @@ -220,7 +220,7 @@ async function handleSubmit() { width: 100%; padding: 0.6rem; background: var(--color-primary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; diff --git a/frontend/src/views/ResetPasswordView.vue b/frontend/src/views/ResetPasswordView.vue index a7e2816..92a4af8 100644 --- a/frontend/src/views/ResetPasswordView.vue +++ b/frontend/src/views/ResetPasswordView.vue @@ -199,7 +199,7 @@ async function handleSubmit() { width: 100%; padding: 0.6rem; background: var(--color-primary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index fbcf524..390ecca 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -2567,7 +2567,7 @@ function formatUserDate(iso: string): string { .btn-save { padding: 0.4rem 0.9rem; background: var(--color-action-primary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; @@ -2595,7 +2595,7 @@ function formatUserDate(iso: string): string { } .btn-danger-outline:hover:not(:disabled) { background: var(--color-action-destructive); - color: #fff; + color: var(--fs-text-on-action); } .btn-danger-outline:disabled { opacity: 0.5; cursor: default; } @@ -2603,7 +2603,7 @@ function formatUserDate(iso: string): string { .btn-danger { padding: 0.4rem 0.9rem; background: var(--color-action-destructive); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; @@ -2620,7 +2620,7 @@ function formatUserDate(iso: string): string { .btn-secondary { padding: 0.4rem 0.9rem; background: var(--color-action-secondary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; @@ -2685,7 +2685,7 @@ function formatUserDate(iso: string): string { .db-health-table tr.dh-warn td:first-child code { color: var(--color-warning); } .btn-warn:hover:not(:disabled) { background: var(--color-warning); - color: #fff; + color: var(--fs-text-on-action); } .saved-msg { @@ -3000,7 +3000,7 @@ function formatUserDate(iso: string): string { .btn-confirm-delete { padding: 0.25rem 0.6rem; background: var(--color-action-destructive); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; @@ -3033,7 +3033,7 @@ function formatUserDate(iso: string): string { transition: background 0.15s; } .btn-toggle:disabled { opacity: 0.6; cursor: default; } -.btn-toggle-open { background: var(--color-action-primary); color: #fff; } +.btn-toggle-open { background: var(--color-action-primary); color: var(--fs-text-on-action); } .btn-toggle-open:hover:not(:disabled) { background: var(--color-action-primary-hover); } .btn-toggle-close { background: var(--color-bg-secondary); @@ -3151,7 +3151,7 @@ function formatUserDate(iso: string): string { .btn-primary { padding: 0.4rem 0.9rem; background: var(--color-action-primary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; @@ -3391,7 +3391,7 @@ function formatUserDate(iso: string): string { } .unit-btn.active { background: var(--color-action-primary); - color: #fff; + color: var(--fs-text-on-action); } .unit-btn:hover:not(.active) { color: var(--color-text); @@ -3863,7 +3863,7 @@ function formatUserDate(iso: string): string { } .btn-danger-outline:hover:not(:disabled) { background: var(--color-action-destructive); - color: #fff; + color: var(--fs-text-on-action); } .btn-danger-outline:disabled { opacity: 0.5; cursor: default; } @keyframes va-dot-bounce { diff --git a/frontend/src/views/SnippetDetailView.vue b/frontend/src/views/SnippetDetailView.vue index 82bea87..70cd7cd 100644 --- a/frontend/src/views/SnippetDetailView.vue +++ b/frontend/src/views/SnippetDetailView.vue @@ -268,7 +268,7 @@ async function confirmDelete() { padding: 0.35rem 0.8rem; border: none; background: var(--color-action-destructive, #6B2118); - color: #fff; + color: var(--fs-text-on-action); border-radius: var(--radius-sm); cursor: pointer; font-size: 0.85rem; diff --git a/frontend/src/views/SnippetEditorView.vue b/frontend/src/views/SnippetEditorView.vue index 7420b6d..c672fe6 100644 --- a/frontend/src/views/SnippetEditorView.vue +++ b/frontend/src/views/SnippetEditorView.vue @@ -575,7 +575,7 @@ function cancel() { .btn-primary { padding: 0.5rem 1.1rem; background: var(--color-action-primary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; diff --git a/frontend/src/views/SnippetListView.vue b/frontend/src/views/SnippetListView.vue index 411df7f..6f7606b 100644 --- a/frontend/src/views/SnippetListView.vue +++ b/frontend/src/views/SnippetListView.vue @@ -546,7 +546,7 @@ function usageTitle(s: SnippetListItem): string { .btn-primary { padding: 0.45rem 1rem; background: var(--color-action-primary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; @@ -679,7 +679,7 @@ function usageTitle(s: SnippetListItem): string { } .empty-action:hover { background: var(--color-primary); - color: #fff; + color: var(--fs-text-on-action); } .skeleton-grid, @@ -1021,7 +1021,7 @@ function usageTitle(s: SnippetListItem): string { .modal-btn-primary { background: var(--color-action-primary); border-color: var(--color-action-primary); - color: #fff; + color: var(--fs-text-on-action); } .modal-btn-primary:hover:not(:disabled) { background: var(--color-action-primary-hover); diff --git a/frontend/src/views/TaskEditorView.vue b/frontend/src/views/TaskEditorView.vue index 4617777..6032a11 100644 --- a/frontend/src/views/TaskEditorView.vue +++ b/frontend/src/views/TaskEditorView.vue @@ -971,7 +971,7 @@ useEditorGuards(dirty, save); .btn-subtask-confirm { padding: 0.25rem 0.5rem; background: var(--color-primary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; diff --git a/frontend/src/views/TaskViewerView.vue b/frontend/src/views/TaskViewerView.vue index f9065ef..44e7fbf 100644 --- a/frontend/src/views/TaskViewerView.vue +++ b/frontend/src/views/TaskViewerView.vue @@ -501,7 +501,7 @@ const subTaskProgress = computed(() => { border: none; border-radius: var(--radius-sm); background: var(--color-action-primary); - color: #fff; + color: var(--fs-text-on-action); text-decoration: none; cursor: pointer; font-size: 0.875rem; @@ -511,14 +511,14 @@ const subTaskProgress = computed(() => { .btn-edit:hover, .btn-advance:hover { background: var(--color-action-primary-hover); - color: #fff; + color: var(--fs-text-on-action); } /* Convert + Share: Bronze action-secondary — alternate paths */ .btn-convert { margin-left: auto; padding: 0.3rem 0.75rem; background: var(--color-action-secondary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; @@ -536,7 +536,7 @@ const subTaskProgress = computed(() => { background: var(--color-action-secondary); border: none; border-radius: var(--radius-sm); - color: #fff; + color: var(--fs-text-on-action); cursor: pointer; font-size: 0.85rem; font-family: inherit; diff --git a/frontend/src/views/UserManagementView.vue b/frontend/src/views/UserManagementView.vue index 95a165d..1fb1721 100644 --- a/frontend/src/views/UserManagementView.vue +++ b/frontend/src/views/UserManagementView.vue @@ -331,7 +331,7 @@ function formatDate(iso: string): string { .btn-invite { padding: 0.45rem 1rem; background: var(--color-primary); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; @@ -395,7 +395,7 @@ function formatDate(iso: string): string { } .btn-toggle-open { background: var(--color-primary); - color: #fff; + color: var(--fs-text-on-action); } .btn-toggle-open:hover:not(:disabled) { opacity: 0.9; @@ -498,7 +498,7 @@ function formatDate(iso: string): string { .btn-confirm-delete { padding: 0.25rem 0.6rem; background: var(--color-danger); - color: #fff; + color: var(--fs-text-on-action); border: none; border-radius: var(--radius-sm); cursor: pointer; diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 4b3cdd2..a486db8 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -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.20", + "version": "0.1.21", "author": { "name": "Bryan Van Deusen" }, "mcpServers": { "scribe": { diff --git a/plugin/skills/using-scribe/SKILL.md b/plugin/skills/using-scribe/SKILL.md index fab80e1..8562d58 100644 --- a/plugin/skills/using-scribe/SKILL.md +++ b/plugin/skills/using-scribe/SKILL.md @@ -64,11 +64,13 @@ Two constraints on *how* that's achieved: 3. **Update over duplicate.** When recording, prefer updating an existing note/rule/task over creating a new one. Search first; revise what's there. -4. **Plans live in Scribe.** For non-trivial work call `start_planning(project_id, - title)` FIRST — it creates a milestone whose `body` holds the design; each - step is its own task under that milestone (`create_task(milestone_id=...)`), - progress goes in work-logs (`add_task_log`). Read it back with `get_milestone`. - Do not write plans/specs to local `.md` files. +4. **When you plan, plan in Scribe.** Work with an *arc* — several steps toward + one goal — gets a plan, and a plan is a milestone: `start_planning(project_id, + title)` creates one whose `body` holds the design, each step is its own task + under it (`create_task(milestone_id=...)`), progress goes in work-logs + (`add_task_log`). Work without an arc (a fix, a one-file change, a question) + is just a task — don't wrap it in a milestone. Either way, do not write + plans/specs to local `.md` files. See the **writing-plans** skill. 5. **Keep state honest.** Set a task `in_progress` when you start it, `done` the moment it's complete; log progress as you go. @@ -102,7 +104,7 @@ shared homes general: norms that bind *every* project. Cross-project standards only. - **Subscribed rulebook** (`create_rule` + `subscribe_project_to_rulebook`) — a reusable, *themed* module of general rules that binds only projects that opt - in (e.g. a design system → visual apps). Themed, but still project-agnostic. + in (e.g. a review checklist → every service). Themed, but project-agnostic. - **Project rule** (`create_project_rule`) — anything specific to one project (its files, paths, quirks). @@ -112,6 +114,26 @@ project rule; a standard a category shares → subscribed rulebook; a universal norm → always-on rulebook. Never put project-specific detail in a shared rulebook — it leaks to every other project that gets it. +**First ask whether it's a rule at all.** A rule is prose you have to remember +and apply; Scribe's other entities are structure a tool can resolve and check. +Visual standards belong in a **design system**, not a rulebook — a token can be +inherited, resolved per mode, rendered to a stylesheet and diffed against code, +and none of that survives being written as a rule. A repeatable procedure is a +**process**; reusable code is a **snippet**. Reach for a rule when the thing +really is a standing instruction about how to work. + +## Building UI: the project's design system binds + +`enter_project` returns a `design_system` when the project has one, with the +guidance **chain-merged** — the house style it inherits plus its own departures +from it. Treat it the way you treat a rule. + +Before writing a colour, size, radius, weight or duration by hand, reach for a +token: `resolve_design_system(id)` for the values, or +`get_design_system_stylesheet(id)` for the rendered sheet. A literal is a value +stated outside the system, so it can never follow a palette change — and +nothing will tell you it drifted. + ## Other Scribe process-skills This plugin also ships focused process-skills — writing-plans, systematic diff --git a/plugin/skills/writing-plans/SKILL.md b/plugin/skills/writing-plans/SKILL.md index 8ef8d80..67bf6f7 100644 --- a/plugin/skills/writing-plans/SKILL.md +++ b/plugin/skills/writing-plans/SKILL.md @@ -1,6 +1,6 @@ --- name: writing-plans -description: Use before starting any non-trivial or multi-step piece of work — produce a clear plan BEFORE diving in. Triggers when the user asks you to plan, design an approach, scope an effort, or tackle work big enough to need ordered steps. The plan lives in a Scribe milestone (via start_planning), not a local file. +description: Use when a piece of work has an arc — several steps toward one goal, worth tracking as a unit — and you want the approach reviewable before you start. Triggers when the user asks you to plan, design an approach, or scope an effort, or when work is about to sprawl across several steps. Not for single-step work. The plan lives in a Scribe milestone (via start_planning), not a local file. --- # Writing plans @@ -9,12 +9,30 @@ A plan is **how** you'll execute a chunk of work — the design plus an ordered set of steps — written *before* you start, so the approach is reviewable and the work stays trackable. -## Start the plan in Scribe, not a file +## First decide whether this work wants a plan -For non-trivial work, call **`start_planning(project_id, title)` FIRST** — -before any design or implementation. It creates a **milestone** (the plan -container) seeded with a design template and returns the milestone id plus the -project's applicable rules. The plan lives in that milestone: +A plan lives in a milestone, and **a milestone earns its place when the work has +an arc**: several steps, one shared goal, a beginning and an end worth tracking +as a unit. That is the whole test, and it is a judgment about the *shape* of the +work — not about its size, difficulty, or importance. + +Plenty of real work has no arc. A bug fix, a one-file change, a question +answered, a setting changed. For those, a milestone is a container with one +thing in it: the ceremony costs more than it records, and it leaves the project +with milestones that never meant anything. **Use a task instead** — set it +`in_progress`, record what you find with `add_task_log`, set it `done`. That is +a complete, honest record of work that didn't need a plan. + +Some projects are milestone-shaped and some are a flat task list. Read the +project you are in rather than imposing a shape on it. + +## When it does: start the plan in Scribe, not a file + +Call **`start_planning(project_id, title)`** before designing or implementing — +so the milestone exists to write into, rather than being backfilled from work +already done. It creates a **milestone** (the plan container) seeded with a +design template and returns the milestone id plus the project's applicable +rules. The plan lives in that milestone: - The **design/intent** goes in the milestone `body` — edit it with `update_milestone(milestone_id, body=...)`. diff --git a/scripts/check_design_tokens.py b/scripts/check_design_tokens.py new file mode 100644 index 0000000..558f2b1 --- /dev/null +++ b/scripts/check_design_tokens.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Check the frontend's CSS against the tokens its stylesheet declares. + +The gap this closes: `services/design_stylesheet.check_code_against_tokens` has +always been able to answer "does this code use the sheet correctly?", but the +only thing ever fed to it was recorded SNIPPETS. The app's own components — where +sixteen unresolvable references were found living quietly (#2319) — were checked +by nothing at all. + +That was structural rather than an oversight. The drift panel runs in the browser +and cannot read source files, and the server has no repo access. CI is the only +place holding both the component sources and the ability to run the check, and it +only became cheap once `theme.css` became a generated artifact — so the source of +truth is a local file, with no network and no credentials. + +INSTANCE-AGNOSTIC ON PURPOSE (rule #115). Nothing here knows what a token should +be called or which literals are discouraged. Both come from the stylesheet: the +declarations, and the `SUPERSEDES` block the generator emits. Point it at a +different install's sheet and it checks that install's rules. + +Two severities, and the split is deliberate: + + FAIL an unresolvable `var()` reference. Currently zero, so this is a ratchet + that holds a line already reached rather than a backlog that keeps CI + red. It also cannot false-positive: either the name is declared or it + is not. + REPORT superseded literals and raw colour literals. Hundreds today, so gating + on them would mean a permanently failing job that everyone learns to + ignore — which is worse than no check. +""" +from __future__ import annotations + +import argparse +import pathlib +import re +import sys + +# A declaration is `--name:`; a reference is `var(--name)` or `var(--name, …)`. +DECLARATION = re.compile(r"(?\s*(--[A-Za-z0-9_-]+)\s*$") +HEX_LITERAL = re.compile(r"#[0-9a-fA-F]{3,8}\b") +STYLE_BLOCK = re.compile(r"]*>(.*?)", re.S) +CSS_COMMENT = re.compile(r"/\*.*?\*/", re.S) + + +def declared_tokens(sheet: str) -> set[str]: + """Every custom property the stylesheet declares. + + Anchored on the colon alone. Anchoring on `{` or `;` instead silently drops + every declaration that follows a comment — a mistake made once already, which + lost `--color-bg` and 2 others without erroring. + """ + return set(DECLARATION.findall(sheet)) + + +def superseded_literals(sheet: str) -> dict[str, str]: + """`{literal: token}` from the generator's SUPERSEDES block, lowercased.""" + out: dict[str, str] = {} + for line in sheet.splitlines(): + match = SUPERSEDES_LINE.match(line) + if match: + out[match.group(1).lower()] = match.group(2) + return out + + +def _literal_pattern(literal: str) -> re.Pattern: + """Match a literal without matching a longer one containing it. + + `#fff` must not fire inside `#ffffff`: different colours, and a finding on + the wrong one sends someone to change correct code. + """ + return re.compile( + r"(? str: + """The CSS in a file — `\n' + ) + css = check.style_source(sfc) + assert "--fs-text-primary" in css + assert "#fff" not in css + + +def test_a_comment_explaining_a_rule_is_not_a_violation(tmp_path): + """LOAD-BEARING, and it fired on the first real run. A comment documenting + why a literal is avoided necessarily contains that literal — this codebase's + own stylesheet says so about `#fff`. A checker that flags the documentation + of a rule teaches people to stop documenting rules.""" + sfc = tmp_path / "Y.vue" + sfc.write_text( + "\n" + ) + css = check.style_source(sfc) + assert check._literal_pattern("#fff").search(css) is None + + +def test_a_plain_css_file_is_read_whole(tmp_path): + css_file = tmp_path / "shared.css" + css_file.write_text(".a { color: #fff; }") + assert "#fff" in check.style_source(css_file) + + +# --- the gate --------------------------------------------------------------- + +def _run(tmp_path, sheet: str, component: str, monkeypatch, capsys): + (tmp_path / "assets").mkdir(parents=True, exist_ok=True) + sheet_path = tmp_path / "assets" / "theme.css" + sheet_path.write_text(sheet) + (tmp_path / "C.vue").write_text(f"") + monkeypatch.setattr( + "sys.argv", + ["check", "--sheet", str(sheet_path), "--root", str(tmp_path)], + ) + code = check.main() + return code, capsys.readouterr().out + + +def test_an_unresolvable_reference_fails_the_build(tmp_path, monkeypatch, capsys): + """The one hard gate. It is safe to gate on because the count is zero today — + a ratchet holding a line already reached, not a backlog that keeps CI red.""" + code, out = _run(tmp_path, SHEET, ".a { color: var(--nope); }", monkeypatch, capsys) + assert code == 1 + assert "--nope" in out + + +def test_a_resolvable_reference_passes(tmp_path, monkeypatch, capsys): + code, out = _run( + tmp_path, SHEET, ".a { color: var(--fs-text-primary); }", monkeypatch, capsys + ) + assert code == 0 + assert "every var() reference resolves" in out + + +def test_a_locally_declared_property_is_not_unresolved(tmp_path, monkeypatch, capsys): + """A component may legitimately define its own custom property for local use — + a keyframe variable, a per-instance override. Only a reference to a name that + exists NOWHERE is broken.""" + code, _ = _run( + tmp_path, SHEET, ".a { --local: 4px; padding: var(--local); }", monkeypatch, capsys + ) + assert code == 0 + + +def test_a_superseded_literal_reports_but_does_not_fail(tmp_path, monkeypatch, capsys): + """Hundreds exist. Gating would make a permanently-red job, which is a check + nobody reads — worse than no check at all.""" + code, out = _run(tmp_path, SHEET, ".a { color: #fff; }", monkeypatch, capsys) + assert code == 0 + assert "#fff -> --fs-text-primary" in out + + +def test_a_missing_stylesheet_is_an_error_not_a_pass(tmp_path, monkeypatch, capsys): + """If the sheet moves, the check must fail loudly rather than silently + passing with zero tokens to compare against — which would look identical to + a clean run.""" + monkeypatch.setattr( + "sys.argv", ["check", "--sheet", str(tmp_path / "gone.css"), "--root", str(tmp_path)] + ) + assert check.main() == 2 diff --git a/tests/test_mcp_tool_projects.py b/tests/test_mcp_tool_projects.py index 5328f74..3b52c4e 100644 --- a/tests/test_mcp_tool_projects.py +++ b/tests/test_mcp_tool_projects.py @@ -17,12 +17,16 @@ def _bind_user(): _user_id_ctx.reset(token) -def _fake_project(**overrides) -> MagicMock: +def _fake_project(design_system_id=None, **overrides) -> MagicMock: p = MagicMock() base = {"id": 1, "title": "P", "description": "", "goal": "", "status": "active", "color": None} base.update(overrides) p.to_dict.return_value = base + # Explicit, because a bare MagicMock hands back a truthy auto-attribute — + # which would route every project in this file through the design-system + # branch and out to a real database. + p.design_system_id = design_system_id return p @@ -176,6 +180,45 @@ async def test_enter_project_composes_full_context(): assert out["open_tasks"][0]["id"] == 100 assert out["open_tasks"][0]["status"] == "in_progress" assert out["recent_notes"][0]["id"] == 200 + # No design system on this project -> the key is present and null, not + # absent. A caller that has to distinguish "no key" from "no system" will + # eventually get it wrong. + assert out["design_system"] is None + + +@pytest.mark.asyncio +async def test_enter_project_hands_back_the_design_system_when_the_project_has_one(): + """The handshake is where an agent learns what binds it, and a design + system binds the same way a rule does. Before this it was reachable only by + an agent that already knew to call resolve_design_system — so the standards + were present in the store and absent from the work.""" + p = _fake_project(id=5, design_system_id=9) + design = {"id": 9, "title": "App kit", "guidance": [{"title": "House"}], + "token_count": 95, "token_groups": ["surface"], + "inherits_from": ["House"], "description": ""} + + with patch( + "scribe.mcp.tools.projects.projects_svc.get_project", + AsyncMock(return_value=p), + ), patch( + "scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules", + AsyncMock(return_value={"rules": [], "truncated": False, + "subscribed_rulebooks": []}), + ), patch( + "scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary", + AsyncMock(return_value=[]), + ), patch( + "scribe.mcp.tools.projects.notes_svc.list_notes", + AsyncMock(side_effect=[([], 0), ([], 0)]), + ), patch( + "scribe.mcp.tools.projects.design_systems_svc.design_context", + AsyncMock(return_value=design), + ) as ctx: + out = await enter_project(project_id=5) + + assert out["design_system"]["token_count"] == 95 + assert out["design_system"]["inherits_from"] == ["House"] + assert ctx.await_args.args == (7, 9) # caller's id, the project's system @pytest.mark.asyncio diff --git a/tests/test_note_usage.py b/tests/test_note_usage.py index 92dd7d9..b57f6b3 100644 --- a/tests/test_note_usage.py +++ b/tests/test_note_usage.py @@ -25,6 +25,9 @@ def _note(nid, title, user_id=1): # See the same note in test_write_path_trigger: an auto-created mock on # `.data` is truthy and would leak into a rendered menu line (#2244). n.data = None + # And on `.is_task`, which the kind marker reads FIRST — truthy there makes + # every one of these snippets read as a task (#2246). + n.is_task, n.task_kind = False, "work" return n diff --git a/tests/test_services_backup.py b/tests/test_services_backup.py index 67486be..6908c32 100644 --- a/tests/test_services_backup.py +++ b/tests/test_services_backup.py @@ -13,16 +13,54 @@ import pytest from scribe.services import backup -def test_backup_version_is_v4(): - assert backup.BACKUP_VERSION == 4 +def test_backup_version_is_v5(): + assert backup.BACKUP_VERSION == 5 def test_not_included_lists_the_known_gaps(): # The deferred tables must be surfaced explicitly, not silently dropped. - for table in ("groups", "project_shares", "note_shares", "api_keys", "embeddings"): + for table in ("groups", "project_shares", "note_shares", "api_keys", + "note_embeddings", "retrieval_logs"): assert table in backup._NOT_INCLUDED +def test_every_table_is_either_backed_up_or_explicitly_excluded(): + """THE GUARD (#2293), and the only shape of test that catches an ABSENCE. + + A new table gets a model and a migration — both fail loudly if wrong — and + then silently never gets a backup section. No error, no warning, and a + restore that reports success. That is how `systems`, `record_systems`, + `note_usage_events`, `design_systems`, `design_tokens` and `repo_bindings` + all went missing, over five migrations, with nothing to notice. + + Extending the export fixes today. THIS fixes the next one: adding a table + now fails here until someone either backs it up or states in + `_NOT_INCLUDED` that it shouldn't be. Either is fine; silence is not. + """ + from scribe.models import Base + + schema = set(Base.metadata.tables) + accounted = set(backup._BACKED_UP) | set(backup._NOT_INCLUDED) + + unaccounted = schema - accounted + assert not unaccounted, ( + f"{len(unaccounted)} table(s) are neither backed up nor explicitly " + f"excluded: {sorted(unaccounted)}. Add each to backup._BACKED_UP (and " + f"give it an export + restore section) or to backup._NOT_INCLUDED with " + f"a reason in the comment above it." + ) + + # And the reverse: a name in either list that no longer exists is a lie the + # guard would otherwise keep telling. This half is what caught "embeddings", + # "invitations" and "password_resets" — three entries that named nothing. + phantom = accounted - schema + assert not phantom, ( + f"backup lists table(s) that are not in the schema: {sorted(phantom)}. " + f"Renamed or dropped — fix the list rather than leaving it to read as " + f"coverage." + ) + + def test_join_table_row_helpers_are_pure(): subs = [SimpleNamespace(project_id=1, rulebook_id=2)] rsup = [SimpleNamespace(project_id=1, rule_id=9)] @@ -57,16 +95,18 @@ class _CM: @pytest.mark.asyncio -async def test_export_full_backup_contains_v3_sections(): +async def test_export_full_backup_contains_every_declared_section(): with patch("scribe.services.backup.async_session", lambda: _CM()): out = await backup.export_full_backup() - assert out["version"] == 4 + assert out["version"] == backup.BACKUP_VERSION assert out["scope"] == "full" assert "api_keys" in out["_not_included"] - # The sections v2 silently dropped must now be present (empty here). + # The sections v2 silently dropped, plus the six v5 added (empty here). for key in ("rulebooks", "rulebook_topics", "rules", "rulebook_subscriptions", "rule_suppressions", - "topic_suppressions"): - assert key in out, f"missing v3 section: {key}" + "topic_suppressions", + "systems", "record_systems", "design_systems", + "design_tokens", "note_usage_events", "repo_bindings"): + assert key in out, f"missing export section: {key}" assert out[key] == [] diff --git a/tests/test_services_design_systems.py b/tests/test_services_design_systems.py index 83f0339..303ff31 100644 --- a/tests/test_services_design_systems.py +++ b/tests/test_services_design_systems.py @@ -321,3 +321,94 @@ async def test_stylesheet_reports_what_the_sheet_cannot_say_for_itself(): assert result["duplicates"] == {"#4a5d3f": ["--fs-moss", "--fs-success"]} assert "--fs-moss: #4a5d3f;" in result["css"] assert "FabledSword" in result["css"] + + +# --- design_context: the delivery side -------------------------------------- + +def _ctx_token(name: str, group: str | None): + from scribe.services.design_cascade import ResolvedToken + return ResolvedToken( + name=name, contributions={}, group_name=group, + purpose=None, rationale=None, supersedes=(), order_index=0, + ) + + +@pytest.mark.asyncio +async def test_design_context_merges_guidance_ANCESTOR_FIRST(): + """LOAD-BEARING. A child system holds only what it CHANGES, so its own + guidance describes a departure from a house style it never restates. An + agent handed the leaf alone builds against a fragment with no signal that + the rest exists — which is exactly the failure retiring the design rulebook + would otherwise have caused. + """ + family = MagicMock(id=1, deleted_at=None, owner_user_id=42, + description="the house", guidance="Dark-mode-first.") + family.title = "House" + app = MagicMock(id=3, deleted_at=None, owner_user_id=42, + description="one app", guidance="Accent on the wordmark.") + app.title = "App" + + mock_session = _make_mock_session() + mock_session.get = AsyncMock(return_value=app) + mock_session.execute = AsyncMock(return_value=MagicMock( + scalars=MagicMock(return_value=MagicMock(all=lambda: [app, family])) + )) + + with patch("scribe.services.design_systems.async_session") as mock_cls, \ + patch("scribe.services.design_systems._parent_map", + AsyncMock(return_value={3: 1, 1: None})), \ + patch("scribe.services.design_systems.resolve_design_system", + AsyncMock(return_value=[ + _ctx_token("--a", "surface"), _ctx_token("--b", "type"), + _ctx_token("--c", "surface"), _ctx_token("--d", None), + ])): + mock_cls.return_value = mock_session + from scribe.services.design_systems import design_context + out = await design_context(user_id=42, design_system_id=3) + + assert [g["title"] for g in out["guidance"]] == ["House", "App"] + assert out["inherits_from"] == ["House"] + assert out["token_count"] == 4 + # Group names deduped and sorted; an ungrouped token contributes nothing. + assert out["token_groups"] == ["surface", "type"] + + +@pytest.mark.asyncio +async def test_design_context_denied_returns_none(): + """It rides on resolve_design_system's ACL rather than re-deriving one — + a second permission path is a second thing to get wrong.""" + with patch("scribe.services.design_systems.resolve_design_system", + AsyncMock(return_value=None)): + from scribe.services.design_systems import design_context + assert await design_context(user_id=1, design_system_id=3) is None + + +@pytest.mark.asyncio +async def test_design_context_omits_systems_with_no_guidance(): + """A system that overrides one token and says nothing about it should not + contribute an empty section — a heading with nothing under it reads as + missing content rather than as an absence of content.""" + family = MagicMock(id=1, deleted_at=None, owner_user_id=42, + description="", guidance="Dark-mode-first.") + family.title = "House" + app = MagicMock(id=3, deleted_at=None, owner_user_id=42, + description="", guidance=" ") + app.title = "App" + + mock_session = _make_mock_session() + mock_session.get = AsyncMock(return_value=app) + mock_session.execute = AsyncMock(return_value=MagicMock( + scalars=MagicMock(return_value=MagicMock(all=lambda: [app, family])) + )) + + with patch("scribe.services.design_systems.async_session") as mock_cls, \ + patch("scribe.services.design_systems._parent_map", + AsyncMock(return_value={3: 1, 1: None})), \ + patch("scribe.services.design_systems.resolve_design_system", + AsyncMock(return_value=[])): + mock_cls.return_value = mock_session + from scribe.services.design_systems import design_context + out = await design_context(user_id=42, design_system_id=3) + + assert [g["title"] for g in out["guidance"]] == ["House"] + assert out["token_count"] == 0 diff --git a/tests/test_services_notes.py b/tests/test_services_notes.py new file mode 100644 index 0000000..da74997 --- /dev/null +++ b/tests/test_services_notes.py @@ -0,0 +1,64 @@ +"""services/notes.py — the inline embedding moved here from the routes (#2056). + +Why it moved: embedding was triggered at each REST route and nowhere else, so a +record created through MCP stayed out of semantic search and auto-inject until +the next restart's backfill. Putting it in the service means every caller — REST, +MCP, recurrence, snippets — gets it by construction rather than by remembering. + +These test the helper directly. The point of the change is that there is now ONE +place to test. +""" +from scribe.services import notes as notes_svc + +# --- inline embedding (#2056) ----------------------------------------------- + +def test_embed_note_uses_the_OWNER_not_the_caller(): + """LOAD-BEARING for shared records. An embedding belongs to the record; a + collaborator editing a shared note must refresh the owner's row rather than + mint a second one under their own id. The routes this replaced passed the + caller's uid on one path and the owner's on another — exactly the kind of + inconsistency that moving it to one place removes.""" + from unittest.mock import MagicMock, patch + + note = MagicMock(id=5, user_id=42, title="T", body="B") + with patch("scribe.services.embeddings.upsert_note_embedding") as upsert, \ + patch("asyncio.create_task") as create_task: + notes_svc.embed_note(note) + + assert create_task.called + upsert.assert_called_once() + assert upsert.call_args.args[0] == 5 + assert upsert.call_args.args[1] == 42 # owner, never the caller + assert upsert.call_args.args[2] == "T\nB" + + +def test_embed_note_skips_a_record_with_no_text(): + """An empty embedding is worse than none — it is a row that matches nothing + and hides the fact that the record was never indexed.""" + from unittest.mock import MagicMock, patch + + note = MagicMock(id=5, user_id=42, title="", body="") + with patch("asyncio.create_task") as create_task: + notes_svc.embed_note(note) + assert not create_task.called + + +def test_embed_note_without_a_running_loop_is_not_an_error(): + """Unit tests and scripts call create_note with no event loop. That must be + an ordinary case: a write that succeeded cannot be failed by its index + refresh.""" + from unittest.mock import MagicMock, patch + + note = MagicMock(id=5, user_id=42, title="T", body="B") + with patch("asyncio.create_task", side_effect=RuntimeError("no running loop")): + notes_svc.embed_note(note) # must not raise + + +def test_embed_note_swallows_an_indexing_failure(): + """Same reason, wider net: the embedding model being unavailable must not + turn a successful save into a 500.""" + from unittest.mock import MagicMock, patch + + note = MagicMock(id=5, user_id=42, title="T", body="B") + with patch("asyncio.create_task", side_effect=ValueError("model gone")): + notes_svc.embed_note(note) # must not raise diff --git a/tests/test_services_plugin_context.py b/tests/test_services_plugin_context.py index 42c19b6..82b96e1 100644 --- a/tests/test_services_plugin_context.py +++ b/tests/test_services_plugin_context.py @@ -19,6 +19,9 @@ def _note(nid, title, user_id=1, note_type="note", is_task=False, task_kind="wor # auto-MagicMock is truthy, so every line would read as another user's task. n.user_id = user_id n.note_type, n.is_task, n.task_kind = note_type, is_task, task_kind + # The write-path menu reads note.data for a language tag; an auto-mock there + # is truthy and renders its repr into the marker. + n.data = None return n @@ -130,7 +133,11 @@ async def test_build_session_context_renders_titles_grouped_by_topic(): @pytest.mark.asyncio async def test_build_session_context_includes_project_when_scoped(): - project = MagicMock(id=2, title="FabledScribe", goal="ship it") + # design_system_id explicitly None: a bare MagicMock would hand back a truthy + # auto-attribute and send this through the design branch, which is the + # opposite of what this test is about. + project = MagicMock(id=2, title="FabledScribe", goal="ship it", + design_system_id=None) with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules", AsyncMock(return_value=[_rule(1, "rule", 1)])), \ patch("scribe.services.plugin_context._topic_titles", @@ -145,6 +152,68 @@ async def test_build_session_context_includes_project_when_scoped(): assert out["project"] == {"id": 2, "title": "FabledScribe"} assert "## Active project: FabledScribe (id 2)" in out["context"] assert "Open todo tasks: 4" in out["context"] + # No design system on the project -> no design block at all. An install with + # none is the ordinary case, not a degraded one. + assert "## Design system" not in out["context"] + + +@pytest.mark.asyncio +async def test_build_session_context_pushes_the_projects_design_system(): + """The gap this closes: a design system had no push channel, so its + standards reached a session only if the agent already knew to go looking — + the same silent failure as a token nobody declares.""" + project = MagicMock(id=2, title="App", goal="", design_system_id=9) + design = { + "id": 9, "title": "App kit", "description": "", + "inherits_from": ["House"], + "guidance": [], "token_count": 95, + "token_groups": ["accent", "surface", "type"], + } + with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules", + AsyncMock(return_value=[_rule(1, "rule", 1)])), \ + patch("scribe.services.plugin_context._topic_titles", + AsyncMock(return_value={1: "git-workflow"})), \ + patch("scribe.services.plugin_context.projects_svc.get_project", + AsyncMock(return_value=project)), \ + patch("scribe.services.plugin_context.notes_svc.list_notes", + AsyncMock(return_value=([], 0))), \ + patch("scribe.services.plugin_context.design_systems_svc.design_context", + AsyncMock(return_value=design)): + from scribe.services.plugin_context import build_session_context + out = await build_session_context(user_id=7, project_id=2) + + ctx = out["context"] + assert "## Design system: App kit (id 9) (inherits House)" in ctx + assert "95 tokens across accent, surface, type" in ctx + # A pointer to the values, never the values themselves — a hundred token + # declarations would crowd out the context they are meant to inform. The + # summary carries counts and group NAMES only, which is why design_context + # returns those rather than the resolved tokens. + assert "resolve_design_system(9)" in ctx + assert "get_design_system_stylesheet(9)" in ctx + + +@pytest.mark.asyncio +async def test_build_session_context_survives_an_unreadable_design_system(): + """design_context returns None when the caller may not read the system. + That must degrade to "no design block", not to a crash that costs the + session its rules too.""" + project = MagicMock(id=2, title="App", goal="", design_system_id=9) + with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules", + AsyncMock(return_value=[_rule(1, "rule", 1)])), \ + patch("scribe.services.plugin_context._topic_titles", + AsyncMock(return_value={1: "git-workflow"})), \ + patch("scribe.services.plugin_context.projects_svc.get_project", + AsyncMock(return_value=project)), \ + patch("scribe.services.plugin_context.notes_svc.list_notes", + AsyncMock(return_value=([], 0))), \ + patch("scribe.services.plugin_context.design_systems_svc.design_context", + AsyncMock(return_value=None)): + from scribe.services.plugin_context import build_session_context + out = await build_session_context(user_id=7, project_id=2) + + assert "## Design system" not in out["context"] + assert "## Active project: App (id 2)" in out["context"] @pytest.mark.asyncio @@ -229,3 +298,174 @@ async def test_build_session_context_caps_length(): assert len(out["context"]) <= 9000 + 60 # cap + truncation note assert "truncated" in out["context"] + + +# --- the reuse slot (#2246) -------------------------------------------------- + +_CFG = {"enabled": True, "threshold": 0.55, "top_k": 3} + + +async def _autoinject(main_hits, reuse_hits, cfg=None): + """Run build_autoinject_hint with the two semantic queries stubbed in order: + the unscoped pool first, then the reserved reuse query.""" + calls: list[dict] = [] + + async def fake_search(*_a, **kw): + calls.append(kw) + return reuse_hits if kw.get("note_type") else main_hits + + with patch("scribe.services.plugin_context.get_autoinject_config", + AsyncMock(return_value=dict(cfg or _CFG))), \ + patch("scribe.services.plugin_context.semantic_search_notes", + AsyncMock(side_effect=fake_search)), \ + patch("scribe.services.plugin_context.record_retrieval", MagicMock()), \ + patch("scribe.services.plugin_context.record_surfaced", MagicMock()), \ + patch("scribe.services.plugin_context.owner_names_for", + AsyncMock(return_value={})): + from scribe.services.plugin_context import build_autoinject_hint + out = await build_autoinject_hint(1, "write a debounce helper") + return out, calls + + +@pytest.mark.asyncio +async def test_a_snippet_takes_the_last_slot_when_none_won_on_score(): + """The measured failure: a prompt asking for a helper returned three project + records ABOUT building the retrieval system, and zero snippets. Scribe's own + records are about software work, so they share vocabulary with any coding + prompt while answering none of them.""" + main = [(0.66, _note(1, "Step 3: title-first auto-inject")), + (0.65, _note(2, "Task-reminder dedup query crashes", is_task=True)), + (0.64, _note(3, "Drafter hardening · write-path trigger", is_task=True))] + reuse = [(0.58, _note(9, "debounce — collapse rapid calls", note_type="snippet"))] + + out, calls = await _autoinject(main, reuse) + + assert out["note_ids"] == [1, 2, 9] # last slot displaced, not the first + assert "#9" in out["context"] and "[snippet]" in out["context"] + # The reserved query is scoped to the reuse kinds and asks for exactly one. + assert calls[1]["note_type"] == ("snippet", "process") + assert calls[1]["limit"] == 1 + + +@pytest.mark.asyncio +async def test_the_reserved_query_is_skipped_when_a_snippet_already_won(): + """No second query, and no slot spent twice, when ranking already did the + right thing — the fix must be invisible in the case it isn't needed.""" + main = [(0.81, _note(9, "debounce helper", note_type="snippet")), + (0.80, _note(1, "some task", is_task=True))] + + out, calls = await _autoinject(main, []) + + assert len(calls) == 1 # reserved query never ran + assert out["note_ids"] == [9, 1] + + +@pytest.mark.asyncio +async def test_a_weak_snippet_does_not_buy_the_slot(): + """The reserved hit skips the MARGIN band — that band is what snippets lose + to — but never the threshold. Silence stays the default; a slot spent on an + irrelevant snippet is how a menu teaches people to ignore it.""" + main = [(0.66, _note(1, "a task", is_task=True))] + + out, calls = await _autoinject(main, []) # threshold returned nothing + + assert out["note_ids"] == [1] + assert len(calls) == 2 # it asked, and got nothing + + +@pytest.mark.asyncio +async def test_the_reserved_hit_is_not_held_to_the_margin_band(): + """0.58 is 0.08 below the top hit. Under the band it would survive; the point + is that it must survive even when it wouldn't — a snippet losing to a + same-vocabulary project record by a wide margin is the whole bug.""" + main = [(0.90, _note(1, "a task", is_task=True))] + reuse = [(0.58, _note(9, "debounce", note_type="snippet"))] + + out, _calls = await _autoinject(main, reuse) + + assert 9 in out["note_ids"] + + +@pytest.mark.asyncio +async def test_a_process_counts_as_reuse_too(): + """A stored process answers 'how do we do X here' the same way a snippet + answers 'what do we already have' — both lose to the same project records.""" + main = [(0.70, _note(1, "a task", is_task=True))] + reuse = [(0.60, _note(8, "DRY pass process", note_type="process"))] + + out, _ = await _autoinject(main, reuse) + assert 8 in out["note_ids"] + + # …and one already on the menu suppresses the reserved query. + out2, calls2 = await _autoinject( + [(0.70, _note(8, "DRY pass process", note_type="process"))], []) + assert len(calls2) == 1 + + +# --- write-path widened beyond snippets (#2246, the mirror half) ------------- + +@pytest.mark.asyncio +async def test_write_path_semantic_arm_asks_for_experience_not_just_snippets(): + """The inverse of auto-inject's mistake. This arm was snippets-only, so an + issue saying "we tried this and it deadlocked" could never reach the moment + that code was about to be written — arguably the better prior art, because + it says what NOT to do.""" + from scribe.services import plugin_context as pc + + hits = [(0.72, _note(9, "debounce helper", note_type="snippet")), + (0.70, _note(7, "Debounce dropped the trailing call", is_task=True, + task_kind="issue"))] + search = AsyncMock(return_value=hits) + rec = MagicMock() + with patch.object(pc, "get_writepath_config", + AsyncMock(return_value={"enabled": True, "threshold": 0.6, + "top_k": 3})), \ + patch.object(pc.snippets_svc, "list_snippets", + AsyncMock(return_value=([], 0))), \ + patch.object(pc, "semantic_search_notes", search), \ + patch.object(pc, "record_retrieval", rec), \ + patch.object(pc, "record_surfaced", MagicMock()), \ + patch.object(pc, "owner_names_for", AsyncMock(return_value={})), \ + patch.object(pc, "concept_query", MagicMock(return_value="debounce a callback")): + out = await pc.build_write_path_hint( + 1, "src/utils/debounce.ts", code="x" * 400, + ) + + kw = search.await_args.kwargs + assert kw["note_type"] == ("snippet", "note") + # An open to-do resembling the code answers nothing; an ISSUE carries a root + # cause and a NOTE carries durable knowledge. Only the todo is excluded. + assert kw["task_kind"] == "issue" + # Telemetry must not claim this was a notes-only retrieval any more. + assert rec.call_args.kwargs["is_task"] is None + + +@pytest.mark.asyncio +async def test_write_path_labels_a_non_snippet_hit_with_its_kind(): + """An unlabelled issue on this menu reads as "here is code to reuse", which + is the opposite of what it says. A snippet stays unlabelled — it is the + menu's default and the header's default reading.""" + from scribe.services import plugin_context as pc + + hits = [(0.72, _note(9, "debounce helper", note_type="snippet")), + (0.71, _note(7, "Debounce dropped the trailing call", is_task=True, + task_kind="issue"))] + with patch.object(pc, "get_writepath_config", + AsyncMock(return_value={"enabled": True, "threshold": 0.6, + "top_k": 3})), \ + patch.object(pc.snippets_svc, "list_snippets", + AsyncMock(return_value=([], 0))), \ + patch.object(pc, "semantic_search_notes", AsyncMock(return_value=hits)), \ + patch.object(pc, "record_retrieval", MagicMock()), \ + patch.object(pc, "record_surfaced", MagicMock()), \ + patch.object(pc, "owner_names_for", AsyncMock(return_value={})), \ + patch.object(pc, "concept_query", MagicMock(return_value="debounce a callback")): + out = await pc.build_write_path_hint( + 1, "src/utils/debounce.ts", code="x" * 400, + ) + + ctx = out["context"] + assert "· issue]" in ctx # the issue says what it is + assert '[similar 0.72] "debounce helper"' in ctx # the snippet does not + # The header now names the right opener for each kind. + assert "get_task(id)" in ctx and "get_snippet(id)" in ctx diff --git a/tests/test_shared_write_access.py b/tests/test_shared_write_access.py index 99e498a..50c90bb 100644 --- a/tests/test_shared_write_access.py +++ b/tests/test_shared_write_access.py @@ -40,8 +40,7 @@ async def test_editor_share_can_update_and_writes_as_the_owner(): with patch.object(svc, "get_snippet", AsyncMock(return_value=note)), \ patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)), \ patch.object(svc.notes_svc, "update_note", - AsyncMock(return_value=updated)) as mock_update, \ - patch.object(svc, "_embed_snippet", MagicMock()): + AsyncMock(return_value=updated)) as mock_update: got = await svc.update_snippet(7, 1, name="formatDuration") assert got is updated @@ -107,8 +106,7 @@ async def test_merge_skips_sources_owned_by_someone_else(): with patch.object(svc, "get_snippet", AsyncMock(side_effect=fake_get)), \ patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)), \ patch.object(svc.notes_svc, "update_note", AsyncMock(return_value=target)), \ - patch("scribe.services.trash.delete", AsyncMock(return_value=object())), \ - patch.object(svc, "_embed_snippet", MagicMock()): + patch("scribe.services.trash.delete", AsyncMock(return_value=object())): _note, merged_ids = await svc.merge_snippets(7, 1, [2, 3]) assert merged_ids == [2] diff --git a/tests/test_snippet_merge_provenance.py b/tests/test_snippet_merge_provenance.py index cda60ab..b465f1b 100644 --- a/tests/test_snippet_merge_provenance.py +++ b/tests/test_snippet_merge_provenance.py @@ -39,8 +39,7 @@ async def _run_merge(target, sources, source_ids): patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)), \ patch.object(s.notes_svc, "update_note", AsyncMock(return_value=target)) as mock_update, \ - patch("scribe.services.trash.delete", AsyncMock(return_value=object())), \ - patch.object(s, "_embed_snippet", MagicMock()): + patch("scribe.services.trash.delete", AsyncMock(return_value=object())): await s.merge_snippets(7, target.id, source_ids) return mock_update.await_args.kwargs @@ -88,8 +87,7 @@ async def test_an_ordinary_edit_carries_provenance_forward(): with patch.object(s, "get_snippet", AsyncMock(return_value=note)), \ patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)), \ patch.object(s.notes_svc, "update_note", - AsyncMock(return_value=note)) as mock_update, \ - patch.object(s, "_embed_snippet", MagicMock()): + AsyncMock(return_value=note)) as mock_update: await s.update_snippet(7, 1, signature="f(ms) -> string") kwargs = mock_update.await_args.kwargs @@ -106,8 +104,7 @@ async def test_a_pre_0070_row_keeps_provenance_through_the_body(): with patch.object(s, "get_snippet", AsyncMock(return_value=note)), \ patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)), \ patch.object(s.notes_svc, "update_note", - AsyncMock(return_value=note)) as mock_update, \ - patch.object(s, "_embed_snippet", MagicMock()): + AsyncMock(return_value=note)) as mock_update: await s.update_snippet(7, 1, when_to_use="humanize a ms count") assert "**Merged from:** #5" in mock_update.await_args.kwargs["body"] diff --git a/tests/test_snippet_unmerge.py b/tests/test_snippet_unmerge.py index 4470392..1009504 100644 --- a/tests/test_snippet_unmerge.py +++ b/tests/test_snippet_unmerge.py @@ -44,7 +44,6 @@ async def _run_unmerge(survivor, *, source_alive=None, restore=1): patch("scribe.services.trash.restore_entity", AsyncMock(return_value=restore)), patch.object(s.notes_svc, "update_note", AsyncMock(return_value=survivor)) as upd, - patch.object(s, "_embed_snippet", MagicMock()), ): await s.unmerge_snippet(7, 1, 2) return upd.await_args.kwargs @@ -127,7 +126,6 @@ async def test_a_purged_source_is_refused_and_the_survivor_is_untouched(): patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)), patch("scribe.services.trash.restore_entity", AsyncMock(return_value=None)), patch.object(s.notes_svc, "update_note", AsyncMock()) as upd, - patch.object(s, "_embed_snippet", MagicMock()), ): with pytest.raises(s.UnmergeError, match="purged"): await s.unmerge_snippet(7, 1, 2) @@ -150,7 +148,6 @@ async def test_an_entry_without_attribution_is_refused_not_guessed(): patch.object(s, "get_snippet", AsyncMock(side_effect=fake_get)), patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)), patch.object(s.notes_svc, "update_note", AsyncMock()) as upd, - patch.object(s, "_embed_snippet", MagicMock()), ): with pytest.raises(s.UnmergeError, match="provenance"): await s.unmerge_snippet(7, 1, 2) diff --git a/tests/test_write_path_trigger.py b/tests/test_write_path_trigger.py index 2426315..6f709b5 100644 --- a/tests/test_write_path_trigger.py +++ b/tests/test_write_path_trigger.py @@ -23,13 +23,17 @@ def _snippet_item(nid, title, user_id=1): return {"id": nid, "title": title, "user_id": user_id, "note_type": "snippet"} -def _note(nid, title, user_id=1): +def _note(nid, title, user_id=1, note_type="snippet", is_task=False, task_kind="work"): n = MagicMock() n.id, n.title, n.user_id = nid, title, user_id # Explicitly None, not left to MagicMock's auto-attribute: the semantic arm # reads `note.data` for the snippet's language (#2244), and an auto-created # mock there is truthy, so it would render its repr into the menu line. n.data = None + # Same reasoning, second instance: since #2246 this arm returns issues and + # dev-logs too, so the line names the kind. An auto-mock `is_task` is truthy, + # which would label every snippet here "task". + n.note_type, n.is_task, n.task_kind = note_type, is_task, task_kind return n @@ -183,8 +187,11 @@ async def test_semantic_arm_is_snippet_only_and_browse_scoped(): patch.object(pc, "record_retrieval", MagicMock()): await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE) kwargs = search.await_args.kwargs - # Prior art means snippets — not the dev-log that happens to mention one. - assert kwargs["note_type"] == "snippet" + # Prior art is snippets AND recorded experience (#2246) — an issue saying + # "we tried this and it broke" belongs here. What stays out is the open + # to-do list, which resembles the code and answers nothing. + assert kwargs["note_type"] == ("snippet", "note") + assert kwargs["task_kind"] == "issue" # Nobody asked for this, so it must not reach a one-to-one direct share. assert kwargs["scope"] == "browse"