gentle-pi 4.2.0 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +340 -138
- package/assets/agents/gentle-ai-explore.md +1 -0
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +163 -0
- package/assets/orchestrator-delegation.md +25 -176
- package/assets/orchestrator-tracking.md +1 -1
- package/assets/orchestrator-verification.md +14 -8
- package/assets/orchestrator-writer.md +5 -1
- package/assets/orchestrator.md +11 -11
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +237 -139
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
- package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/assets/diagrams/agent-orchestration.svg +54 -15
- package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
- package/docs/assets/diagrams/measured-routing.svg +49 -0
- package/docs/assets/diagrams/native-review.svg +52 -12
- package/docs/assets/diagrams/odd-workflow.svg +128 -66
- package/docs/assets/diagrams/permission-modes.svg +51 -0
- package/docs/assets/features/audio-notifications.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/bundled-install.md +65 -30
- package/docs/delegated-verification.md +16 -8
- package/docs/gentle-agents-activity.md +16 -6
- package/docs/gentle-music.md +61 -0
- package/docs/gentle-shell-uninstall.md +8 -1
- package/docs/gentle-shell.md +80 -9
- package/docs/install-wizard.md +37 -6
- package/docs/permission-modes.md +8 -3
- package/docs/readme-reference.md +120 -28
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +38 -10
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +270 -48
- package/extensions/gentle-ai.ts +490 -113
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +359 -85
- package/extensions/gentle-todo.ts +43 -9
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/quiet-tools.ts +1 -1
- package/extensions/session-scratch.ts +384 -0
- package/extensions/skill-registry.ts +63 -8
- package/extensions/startup-banner.ts +2 -3
- package/lib/agent-home.ts +8 -3
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +60 -1
- package/lib/agents-runner.ts +107 -9
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-view.ts +3 -1
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/bounded-writer-admission.ts +51 -0
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +2 -4
- package/lib/commit-slice-signal.ts +100 -0
- package/lib/context-backstop.ts +120 -0
- package/lib/delivered-test-lint.ts +80 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/destructive-command-guard.ts +120 -9
- package/lib/external-editor.ts +31 -0
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +146 -36
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +154 -15
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +51 -2
- package/lib/herdr-activity.ts +24 -0
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +20 -5
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-discovery.ts +5 -1
- package/lib/orchestrator-presence.ts +87 -8
- package/lib/orchestrator-scope.ts +31 -3
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/provider-contract-bundle.ts +99 -53
- package/lib/review-candidate-view-owner.ts +29 -19
- package/lib/review-candidate-view.ts +36 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/review-risk-assessment.ts +43 -38
- package/lib/review-session-standing-permission.ts +3 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-freeze.ts +30 -1
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +523 -0
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +29 -28
- package/lib/shell-changes-view.ts +80 -13
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +24 -5
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +30 -0
- package/lib/vim-editor-adapter.ts +11 -8
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/lib/yolo-temp-scratch.ts +69 -0
- package/package.json +1 -2
- package/runtime/gentle-ai-binary.mjs +28 -7
- package/runtime/gentle-shell-bundled.mjs +145 -35
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +152 -13
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +51 -2
- package/runtime/native-review-cli.mjs +19 -4
- package/runtime/review-risk-assessment.mjs +43 -38
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/bundled-engram-migration.mjs +119 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +5 -3
- package/scripts/installer-server.mjs +14 -4
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +94 -19
- package/scripts/pi-update.mjs +34 -6
- package/scripts/run-test-suite.mjs +5 -0
- package/scripts/verify-package-files.mjs +43 -13
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/skills/gentle-ai/SKILL.md +3 -3
- package/skills/work-unit-commits/SKILL.md +5 -3
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agent-risk-escalation.test.ts +37 -10
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +485 -7
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +142 -0
- package/tests/bounded-writer-admission.test.ts +11 -3
- package/tests/bundled-install.test.ts +3 -3
- package/tests/card-content-policy.test.ts +19 -9
- package/tests/child-safety.test.ts +39 -0
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/codegraph-tools.test.ts +123 -2
- package/tests/command-palette.test.ts +15 -5
- package/tests/commit-slice-signal.test.ts +162 -0
- package/tests/context-backstop.test.ts +225 -0
- package/tests/delegate-for-reason-contract.test.ts +92 -24
- package/tests/delivered-test-lint.test.ts +105 -0
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/destructive-command-guard.test.ts +85 -1
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
- package/tests/gauge-theme.test.ts +120 -0
- package/tests/gentle-agents.test.ts +645 -21
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-child-guards.test.ts +61 -0
- package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +292 -13
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +335 -0
- package/tests/gentle-shell-bin.test.ts +163 -0
- package/tests/gentle-shell-bundled.test.ts +392 -176
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +107 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +89 -0
- package/tests/gentle-shell.test.ts +225 -17
- package/tests/gentle-todo.test.ts +190 -0
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +10 -2
- package/tests/installer-windows-bootstrap.test.ts +94 -7
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +17 -4
- package/tests/native-review-parity.test.ts +23 -2
- package/tests/no-self-pause-contract.test.ts +5 -2
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-integration.test.ts +5 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
- package/tests/odd-routing-contract.test.ts +115 -30
- package/tests/orchestrator-budget.test.ts +22 -8
- package/tests/orchestrator-discovery.test.ts +29 -0
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +3 -1
- package/tests/orchestrator-scope.test.ts +80 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/orchestrator-status-publication-contract.test.ts +12 -8
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +24 -20
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update-launcher.test.ts +21 -2
- package/tests/pi-update.test.ts +99 -4
- package/tests/primary-session-prompt.test.ts +101 -0
- package/tests/provider-contract-bundle.test.ts +118 -0
- package/tests/provider-contract-mirror.test.ts +24 -0
- package/tests/quiet-tool-rendering.test.ts +23 -0
- package/tests/rdd-aware-verification-contract.test.ts +29 -11
- package/tests/rdd-status-line.test.ts +79 -0
- package/tests/review-agent-end-preflight.test.ts +6 -0
- package/tests/review-candidate-view.test.ts +49 -0
- package/tests/review-contract-prompt.test.ts +5 -2
- package/tests/review-controller-native-routing.test.ts +14 -1
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +147 -3
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +258 -0
- package/tests/review-owner-identity.test.ts +132 -0
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/review-risk-assessment.test.ts +163 -76
- package/tests/review-session-standing-permission-controller.test.ts +14 -1
- package/tests/review-session-standing-permission.test.ts +27 -1
- package/tests/run-test-suite.test.ts +15 -0
- package/tests/runtime-harness.mjs +16 -6
- package/tests/session-changes-shell.test.ts +1 -1
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-profile-freeze.test.ts +23 -0
- package/tests/session-scratch.test.ts +1011 -0
- package/tests/session-subject-routing-contract.test.ts +2 -1
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +21 -2
- package/tests/shell-changes-view.test.ts +137 -1
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-gauge.test.ts +42 -0
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +50 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/small-task-inline-contract.test.ts +37 -0
- package/tests/standalone-config-home.test.ts +324 -0
- package/tests/startup-banner.test.ts +5 -4
- package/tests/startup-home-repo.test.ts +34 -0
- package/tests/support/orchestrator-modules.ts +1 -0
- package/tests/task-size-routing-contract.test.ts +70 -10
- package/tests/test-scratch-contract.test.ts +102 -0
- package/tests/test-strength-contract.test.ts +36 -1
- package/tests/tool-batching-contract.test.ts +64 -0
- package/tests/verification-proportionality.test.ts +46 -0
- package/tests/verifier-admission.test.ts +154 -0
- package/tests/verify-agent-contract.test.ts +8 -8
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +30 -2
- package/tests/writer-edit-surface-scope.test.ts +3 -0
- package/tests/writer-feature-document-admission.test.ts +156 -0
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +19 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
- package/tests/yolo-temp-scratch.test.ts +71 -0
- package/scripts/mirror-odd-routing.mjs +0 -242
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { isArtboardPath, locateDesignFile, readDesignFile, replaceDesignFile, serializeDesignWrite } from "./design-comments.ts";
|
|
2
|
+
|
|
3
|
+
// The single owner of design/<slug>/handoffs.json (S15): the handoff state of
|
|
4
|
+
// each artboard and the design's chosen artboard. The canvas server (Implement
|
|
5
|
+
// this), the command and the gentle_design tool all write through this module,
|
|
6
|
+
// in the same per-file in-process queue as comments.json, re-reading the file
|
|
7
|
+
// before every write and replacing it with a temp file + rename. A file this
|
|
8
|
+
// module cannot parse is reported and never overwritten.
|
|
9
|
+
|
|
10
|
+
export const DESIGN_HANDOFFS_FILE = "handoffs.json";
|
|
11
|
+
export const DESIGN_HANDOFF_NOTE_LIMIT = 1000;
|
|
12
|
+
export const DESIGN_PREVIEW_URL_LIMIT = 2048;
|
|
13
|
+
const MAX_HANDOFFS = 256;
|
|
14
|
+
const MAX_FILE_BYTES = 1024 * 1024;
|
|
15
|
+
/** The hosts a preview may name: the loopback interface only, as the URL parser normalizes them. */
|
|
16
|
+
const LOOPBACK_HOSTS: ReadonlySet<string> = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
17
|
+
|
|
18
|
+
/** The ODD feature document a design links to (S10): a repository-relative `odd/tasks/<name>.md`. */
|
|
19
|
+
export const DESIGN_FEATURE_PATH = /^odd\/tasks\/[A-Za-z0-9][A-Za-z0-9._-]*\.md$/;
|
|
20
|
+
|
|
21
|
+
export type DesignHandoffStatus = "sent" | "in_progress" | "implemented";
|
|
22
|
+
|
|
23
|
+
export type DesignHandoff = {
|
|
24
|
+
status: DesignHandoffStatus;
|
|
25
|
+
/** The agent was busy when it was sent, so it waits as a follow-up. */
|
|
26
|
+
queued: boolean;
|
|
27
|
+
sentAt: string;
|
|
28
|
+
startedAt: string | null;
|
|
29
|
+
implementedAt: string | null;
|
|
30
|
+
feature?: string;
|
|
31
|
+
note?: string;
|
|
32
|
+
previewUrl?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type DesignHandoffsDocument = { version: 1; chosen: string | null; handoffs: Record<string, DesignHandoff> };
|
|
36
|
+
|
|
37
|
+
/** What markSent replaced, so a failed delivery can put it back. */
|
|
38
|
+
export type DesignHandoffSent = { file: string; entry: DesignHandoff; previousEntry: DesignHandoff | undefined; previousChosen: string | null };
|
|
39
|
+
|
|
40
|
+
export type DesignHandoffsErrorCode = "not-found" | "invalid-file" | "invalid-input" | "invalid-preview-url" | "limit";
|
|
41
|
+
|
|
42
|
+
export class DesignHandoffsError extends Error {
|
|
43
|
+
readonly code: DesignHandoffsErrorCode;
|
|
44
|
+
constructor(code: DesignHandoffsErrorCode, message: string) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = "DesignHandoffsError";
|
|
47
|
+
this.code = code;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DesignHandoffStoreDeps {
|
|
52
|
+
now?: () => Date;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface DesignHandoffStore {
|
|
56
|
+
/** The design's handoffs; a missing handoffs.json is an empty document. */
|
|
57
|
+
read(slug: string): Promise<DesignHandoffsDocument>;
|
|
58
|
+
/** A new handoff of `file`: status sent, its timestamps reset, and `file` becomes the chosen artboard. */
|
|
59
|
+
markSent(slug: string, file: string, options: { queued: boolean; feature?: string }): Promise<DesignHandoffSent>;
|
|
60
|
+
/**
|
|
61
|
+
* The agent's progress on `file`. `note` and `previewUrl` become exactly what this call passes.
|
|
62
|
+
* An artboard with no handoff yet is recorded as handed off now and becomes the chosen one.
|
|
63
|
+
*/
|
|
64
|
+
setStatus(slug: string, file: string, status: Exclude<DesignHandoffStatus, "sent">, options: { note?: string; previewUrl?: string }): Promise<DesignHandoff>;
|
|
65
|
+
/** Marks the handoff `sent` recorded as queued, unless it changed since; true when it was updated. */
|
|
66
|
+
markQueued(slug: string, sent: DesignHandoffSent): Promise<boolean>;
|
|
67
|
+
/** Puts back what `sent` replaced, unless the handoff changed since; true when it was restored. */
|
|
68
|
+
revertSent(slug: string, sent: DesignHandoffSent): Promise<boolean>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Why `value` cannot be a preview URL, or undefined when it can: an http(s) URL
|
|
73
|
+
* of the loopback interface (localhost, 127.0.0.1 or [::1], any port), without
|
|
74
|
+
* credentials, whitespace or control characters, at most 2048 characters.
|
|
75
|
+
*/
|
|
76
|
+
export function designPreviewUrlProblem(value: unknown): string | undefined {
|
|
77
|
+
if (typeof value !== "string" || value.length === 0) return "previewUrl must be a non-empty string";
|
|
78
|
+
if (value.length > DESIGN_PREVIEW_URL_LIMIT) return `previewUrl must have at most ${DESIGN_PREVIEW_URL_LIMIT} characters`;
|
|
79
|
+
// The URL parser silently drops tabs and newlines and trims spaces; refuse them instead.
|
|
80
|
+
if (/[\s\u0000-\u001f\u007f]/.test(value)) return "previewUrl must not contain whitespace or control characters";
|
|
81
|
+
let url: URL;
|
|
82
|
+
try {
|
|
83
|
+
url = new URL(value);
|
|
84
|
+
} catch {
|
|
85
|
+
return "previewUrl must be an absolute URL";
|
|
86
|
+
}
|
|
87
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return "previewUrl must use http or https";
|
|
88
|
+
if (url.username || url.password) return "previewUrl must not carry credentials";
|
|
89
|
+
if (!LOOPBACK_HOSTS.has(url.hostname)) return "previewUrl must point at localhost, 127.0.0.1 or [::1]";
|
|
90
|
+
if (url.href.length > DESIGN_PREVIEW_URL_LIMIT) return `previewUrl must have at most ${DESIGN_PREVIEW_URL_LIMIT} characters`;
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isTimestamp(value: unknown): value is string {
|
|
95
|
+
return typeof value === "string" && value.length > 0 && value.length <= 64;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function noteProblem(value: unknown): string | undefined {
|
|
99
|
+
if (typeof value !== "string") return "note must be a string";
|
|
100
|
+
if (value.length > DESIGN_HANDOFF_NOTE_LIMIT) return `note must have at most ${DESIGN_HANDOFF_NOTE_LIMIT} characters`;
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function normalizeNote(value: string): string {
|
|
105
|
+
return value.replace(/\r\n?/g, "\n").trim();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The entry with its keys in a stable order and no absent optional keys. */
|
|
109
|
+
function entryOf(fields: DesignHandoff): DesignHandoff {
|
|
110
|
+
const entry: DesignHandoff = {
|
|
111
|
+
status: fields.status,
|
|
112
|
+
queued: fields.queued,
|
|
113
|
+
sentAt: fields.sentAt,
|
|
114
|
+
startedAt: fields.startedAt,
|
|
115
|
+
implementedAt: fields.implementedAt,
|
|
116
|
+
};
|
|
117
|
+
if (fields.feature !== undefined) entry.feature = fields.feature;
|
|
118
|
+
if (fields.note !== undefined) entry.note = fields.note;
|
|
119
|
+
if (fields.previewUrl !== undefined) entry.previewUrl = fields.previewUrl;
|
|
120
|
+
return entry;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sameEntry(a: DesignHandoff | undefined, b: DesignHandoff | undefined): boolean {
|
|
124
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function invalid(message: string): DesignHandoffsError {
|
|
128
|
+
return new DesignHandoffsError("invalid-file", message);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseEntry(where: string, value: unknown): DesignHandoff {
|
|
132
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw invalid(`${where} must be an object`);
|
|
133
|
+
const item = value as Record<string, unknown>;
|
|
134
|
+
if (item.status !== "sent" && item.status !== "in_progress" && item.status !== "implemented") throw invalid(`${where}.status must be "sent", "in_progress" or "implemented"`);
|
|
135
|
+
if (typeof item.queued !== "boolean") throw invalid(`${where}.queued must be true or false`);
|
|
136
|
+
if (!isTimestamp(item.sentAt)) throw invalid(`${where}.sentAt must be a timestamp`);
|
|
137
|
+
for (const key of ["startedAt", "implementedAt"] as const) {
|
|
138
|
+
if (item[key] !== null && !isTimestamp(item[key])) throw invalid(`${where}.${key} must be a timestamp or null`);
|
|
139
|
+
}
|
|
140
|
+
if (item.feature !== undefined && !(typeof item.feature === "string" && DESIGN_FEATURE_PATH.test(item.feature))) throw invalid(`${where}.feature must be a relative odd/tasks/<name>.md path`);
|
|
141
|
+
if (item.note !== undefined) {
|
|
142
|
+
const problem = noteProblem(item.note);
|
|
143
|
+
if (problem) throw invalid(`${where}.${problem}`);
|
|
144
|
+
}
|
|
145
|
+
if (item.previewUrl !== undefined) {
|
|
146
|
+
const problem = designPreviewUrlProblem(item.previewUrl);
|
|
147
|
+
if (problem) throw invalid(`${where}.${problem}`);
|
|
148
|
+
}
|
|
149
|
+
return entryOf(item as unknown as DesignHandoff);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parseDocument(raw: string): DesignHandoffsDocument {
|
|
153
|
+
let value: unknown;
|
|
154
|
+
try {
|
|
155
|
+
value = JSON.parse(raw);
|
|
156
|
+
} catch {
|
|
157
|
+
throw invalid("handoffs.json is not valid JSON");
|
|
158
|
+
}
|
|
159
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw invalid("handoffs.json must be a JSON object");
|
|
160
|
+
const record = value as Record<string, unknown>;
|
|
161
|
+
if (record.version !== 1) throw invalid("handoffs.json version must be 1");
|
|
162
|
+
if (!record.handoffs || typeof record.handoffs !== "object" || Array.isArray(record.handoffs)) throw invalid("handoffs.json handoffs must be an object");
|
|
163
|
+
const entries = Object.entries(record.handoffs as Record<string, unknown>);
|
|
164
|
+
if (entries.length > MAX_HANDOFFS) throw invalid(`handoffs.json may hold at most ${MAX_HANDOFFS} handoffs`);
|
|
165
|
+
// Keys are artboard paths ending in .html, so no key can name an Object prototype member.
|
|
166
|
+
const handoffs: Record<string, DesignHandoff> = {};
|
|
167
|
+
for (const [file, entry] of entries) {
|
|
168
|
+
if (!isArtboardPath(file)) throw invalid(`handoffs.json key ${JSON.stringify(file)} must be a relative .html artboard path`);
|
|
169
|
+
handoffs[file] = parseEntry(`handoffs[${JSON.stringify(file)}]`, entry);
|
|
170
|
+
}
|
|
171
|
+
const chosen = record.chosen ?? null;
|
|
172
|
+
if (chosen === null) return { version: 1, chosen: null, handoffs };
|
|
173
|
+
if (typeof chosen !== "string" || !Object.hasOwn(handoffs, chosen)) throw invalid("handoffs.json chosen must be null or an artboard with a handoff");
|
|
174
|
+
return { version: 1, chosen, handoffs };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function createDesignHandoffStore(designRoot: string, deps: DesignHandoffStoreDeps = {}): DesignHandoffStore {
|
|
178
|
+
const now = deps.now ?? (() => new Date());
|
|
179
|
+
|
|
180
|
+
function locate(slug: string): Promise<{ dir: string; path: string }> {
|
|
181
|
+
return locateDesignFile(designRoot, slug, DESIGN_HANDOFFS_FILE, (message) => new DesignHandoffsError("not-found", message));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function load(path: string): Promise<DesignHandoffsDocument> {
|
|
185
|
+
const raw = await readDesignFile(path, MAX_FILE_BYTES, () => invalid("handoffs.json is too large"));
|
|
186
|
+
return raw === undefined ? { version: 1, chosen: null, handoffs: {} } : parseDocument(raw);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Read-modify-write in the file's queue; `change` sets `write: false` when nothing changed. */
|
|
190
|
+
async function mutate<T>(slug: string, change: (document: DesignHandoffsDocument) => { result: T; write: boolean }): Promise<T> {
|
|
191
|
+
const { dir, path } = await locate(slug);
|
|
192
|
+
return serializeDesignWrite(path, async () => {
|
|
193
|
+
const document = await load(path);
|
|
194
|
+
const { result, write } = change(document);
|
|
195
|
+
if (write) await replaceDesignFile(dir, path, DESIGN_HANDOFFS_FILE, `${JSON.stringify(document, null, 2)}\n`);
|
|
196
|
+
return result;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function checkFile(file: unknown): asserts file is string {
|
|
201
|
+
if (!isArtboardPath(file)) throw new DesignHandoffsError("invalid-input", "file must be a relative .html path inside the design");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function checkRoom(document: DesignHandoffsDocument, slug: string, file: string): void {
|
|
205
|
+
if (!Object.hasOwn(document.handoffs, file) && Object.keys(document.handoffs).length >= MAX_HANDOFFS) {
|
|
206
|
+
throw new DesignHandoffsError("limit", `design/${slug} already has ${MAX_HANDOFFS} handoffs, the most a design can hold.`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
async read(slug) {
|
|
212
|
+
return load((await locate(slug)).path);
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
async markSent(slug, file, options) {
|
|
216
|
+
checkFile(file);
|
|
217
|
+
if (!options || typeof options.queued !== "boolean") throw new DesignHandoffsError("invalid-input", "queued must be true or false");
|
|
218
|
+
if (options.feature !== undefined && !(typeof options.feature === "string" && DESIGN_FEATURE_PATH.test(options.feature))) {
|
|
219
|
+
throw new DesignHandoffsError("invalid-input", "feature must be a relative odd/tasks/<name>.md path");
|
|
220
|
+
}
|
|
221
|
+
return mutate(slug, (document) => {
|
|
222
|
+
checkRoom(document, slug, file);
|
|
223
|
+
const previousEntry = Object.hasOwn(document.handoffs, file) ? document.handoffs[file] : undefined;
|
|
224
|
+
const previousChosen = document.chosen;
|
|
225
|
+
const entry = entryOf({ status: "sent", queued: options.queued, sentAt: now().toISOString(), startedAt: null, implementedAt: null, feature: options.feature });
|
|
226
|
+
document.handoffs[file] = entry;
|
|
227
|
+
document.chosen = file;
|
|
228
|
+
return { result: { file, entry, previousEntry, previousChosen }, write: true };
|
|
229
|
+
});
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
async setStatus(slug, file, status, options) {
|
|
233
|
+
checkFile(file);
|
|
234
|
+
if (status !== "in_progress" && status !== "implemented") throw new DesignHandoffsError("invalid-input", 'status must be "in_progress" or "implemented"');
|
|
235
|
+
const { note, previewUrl } = options ?? {};
|
|
236
|
+
if (note !== undefined) {
|
|
237
|
+
const problem = noteProblem(note);
|
|
238
|
+
if (problem) throw new DesignHandoffsError("invalid-input", problem);
|
|
239
|
+
}
|
|
240
|
+
let preview: string | undefined;
|
|
241
|
+
if (previewUrl !== undefined) {
|
|
242
|
+
const problem = designPreviewUrlProblem(previewUrl);
|
|
243
|
+
if (problem) throw new DesignHandoffsError("invalid-preview-url", problem);
|
|
244
|
+
preview = new URL(previewUrl).href;
|
|
245
|
+
}
|
|
246
|
+
const cleanNote = note === undefined ? undefined : normalizeNote(note) || undefined;
|
|
247
|
+
return mutate(slug, (document) => {
|
|
248
|
+
checkRoom(document, slug, file);
|
|
249
|
+
const stamp = now().toISOString();
|
|
250
|
+
const current = Object.hasOwn(document.handoffs, file) ? document.handoffs[file] : undefined;
|
|
251
|
+
const base = current ?? { status, queued: false, sentAt: stamp, startedAt: null, implementedAt: null };
|
|
252
|
+
const entry = entryOf({
|
|
253
|
+
...base,
|
|
254
|
+
status,
|
|
255
|
+
startedAt: status === "in_progress" ? stamp : base.startedAt,
|
|
256
|
+
implementedAt: status === "implemented" ? stamp : null,
|
|
257
|
+
note: cleanNote,
|
|
258
|
+
previewUrl: preview,
|
|
259
|
+
});
|
|
260
|
+
document.handoffs[file] = entry;
|
|
261
|
+
// Progress on an artboard nobody handed off is itself the choice of that artboard.
|
|
262
|
+
if (!current) document.chosen = file;
|
|
263
|
+
return { result: entry, write: true };
|
|
264
|
+
});
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
async markQueued(slug, sent) {
|
|
268
|
+
return mutate(slug, (document) => {
|
|
269
|
+
const current = Object.hasOwn(document.handoffs, sent.file) ? document.handoffs[sent.file] : undefined;
|
|
270
|
+
if (!sameEntry(current, sent.entry) || sent.entry.queued) return { result: false, write: false };
|
|
271
|
+
document.handoffs[sent.file] = entryOf({ ...sent.entry, queued: true });
|
|
272
|
+
return { result: true, write: true };
|
|
273
|
+
});
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
async revertSent(slug, sent) {
|
|
277
|
+
return mutate(slug, (document) => {
|
|
278
|
+
const current = Object.hasOwn(document.handoffs, sent.file) ? document.handoffs[sent.file] : undefined;
|
|
279
|
+
if (!sameEntry(current, sent.entry)) return { result: false, write: false };
|
|
280
|
+
if (sent.previousEntry) document.handoffs[sent.file] = sent.previousEntry;
|
|
281
|
+
else delete document.handoffs[sent.file];
|
|
282
|
+
if (document.chosen === sent.file) document.chosen = sent.previousChosen !== null && Object.hasOwn(document.handoffs, sent.previousChosen) ? sent.previousChosen : null;
|
|
283
|
+
return { result: true, write: true };
|
|
284
|
+
});
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export type DesignHandoffDelivery = { queued: boolean } | void | undefined;
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Records the handoff of `file` (sent, and chosen) BEFORE `deliver` sends it, so
|
|
293
|
+
* the agent can never update a handoff that is not on disk yet. The delivery says
|
|
294
|
+
* whether it was queued behind a busy agent; a failed delivery takes the record back.
|
|
295
|
+
*/
|
|
296
|
+
export async function sendDesignHandoff(
|
|
297
|
+
store: DesignHandoffStore,
|
|
298
|
+
handoff: { slug: string; file: string; feature?: string },
|
|
299
|
+
deliver: () => DesignHandoffDelivery | Promise<DesignHandoffDelivery>,
|
|
300
|
+
): Promise<{ queued: boolean }> {
|
|
301
|
+
const sent = await store.markSent(handoff.slug, handoff.file, { queued: false, feature: handoff.feature });
|
|
302
|
+
let delivery: DesignHandoffDelivery;
|
|
303
|
+
try {
|
|
304
|
+
delivery = await deliver();
|
|
305
|
+
} catch (error) {
|
|
306
|
+
await store.revertSent(handoff.slug, sent).catch(() => {});
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
const queued = typeof delivery === "object" && delivery !== null && delivery.queued === true;
|
|
310
|
+
// The handoff is already delivered; a failure to note the queue must not report it as failed.
|
|
311
|
+
if (queued) await store.markQueued(handoff.slug, sent).catch(() => false);
|
|
312
|
+
return { queued };
|
|
313
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import { win32 } from "node:path";
|
|
3
|
+
|
|
4
|
+
type SpawnedChild = {
|
|
5
|
+
once(event: "spawn", listener: () => void): unknown;
|
|
6
|
+
once(event: "error", listener: (error: Error) => void): unknown;
|
|
7
|
+
unref(): void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type OpenInBrowserDeps = {
|
|
11
|
+
platform?: NodeJS.Platform;
|
|
12
|
+
spawn?: (command: string, args: readonly string[], options: Record<string, unknown>) => SpawnedChild;
|
|
13
|
+
env?: NodeJS.ProcessEnv;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type OpenInBrowserResult = { opened: boolean; error?: string };
|
|
17
|
+
|
|
18
|
+
/** Fixed per-platform opener, always spawned with shell:false and an absolute path where Windows needs one. */
|
|
19
|
+
function openerFor(platform: NodeJS.Platform, url: string, env: NodeJS.ProcessEnv): { command: string; args: string[] } | string {
|
|
20
|
+
if (platform === "darwin") return { command: "/usr/bin/open", args: [url] };
|
|
21
|
+
if (platform === "win32") {
|
|
22
|
+
// Absolute path: Windows would otherwise search the current directory first.
|
|
23
|
+
const systemRoot = env.SystemRoot ?? env.SYSTEMROOT;
|
|
24
|
+
if (typeof systemRoot !== "string" || !win32.isAbsolute(systemRoot)) return "SystemRoot is not set to an absolute path";
|
|
25
|
+
return { command: win32.join(systemRoot, "System32", "rundll32.exe"), args: ["url.dll,FileProtocolHandler", url] };
|
|
26
|
+
}
|
|
27
|
+
return { command: "xdg-open", args: [url] };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Only the canvas server's own loopback URL may reach an OS opener. */
|
|
31
|
+
function isLoopbackHttpUrl(url: string): boolean {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = new URL(url);
|
|
34
|
+
return parsed.protocol === "http:" && (parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost") && parsed.href === url;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Opens a loopback URL in the default browser; resolves instead of throwing when that is impossible. */
|
|
41
|
+
export function openInBrowser(url: string, deps: OpenInBrowserDeps = {}): Promise<OpenInBrowserResult> {
|
|
42
|
+
if (!isLoopbackHttpUrl(url)) return Promise.resolve({ opened: false, error: "only loopback http URLs can be opened" });
|
|
43
|
+
const opener = openerFor(deps.platform ?? process.platform, url, deps.env ?? process.env);
|
|
44
|
+
if (typeof opener === "string") return Promise.resolve({ opened: false, error: opener });
|
|
45
|
+
const spawn = deps.spawn ?? (nodeSpawn as unknown as NonNullable<OpenInBrowserDeps["spawn"]>);
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
let child: SpawnedChild;
|
|
48
|
+
try {
|
|
49
|
+
child = spawn(opener.command, opener.args, { detached: true, stdio: "ignore", windowsHide: true, shell: false });
|
|
50
|
+
} catch (error) {
|
|
51
|
+
resolve({ opened: false, error: error instanceof Error ? error.message : String(error) });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
child.once("error", (error) => resolve({ opened: false, error: error.message }));
|
|
55
|
+
child.once("spawn", () => resolve({ opened: true }));
|
|
56
|
+
child.unref();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { closeSync, openSync, readdirSync, readSync, statSync, type Dirent } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Design sources (S13): the files a design-system sync reads, found by a
|
|
5
|
+
// bounded, deterministic walk of the project, so the gentle_design tool can
|
|
6
|
+
// say whether design/system.md is missing or older than them.
|
|
7
|
+
//
|
|
8
|
+
// A design source is:
|
|
9
|
+
// - tailwind.config.{js,cjs,mjs,ts};
|
|
10
|
+
// - a .css/.scss/.pcss file that contains `@theme`, or declares a custom
|
|
11
|
+
// property (`--name:`) in the first block of a `:root` or `[data-theme…]`
|
|
12
|
+
// rule (comments are ignored; nested blocks are not parsed);
|
|
13
|
+
// - a .json/.js/.ts/.css file whose name contains "token" (any case);
|
|
14
|
+
// - theme.{js,ts} that exports something (`export` or `module.exports`) and
|
|
15
|
+
// has a `colors:`/`colours:` (or `=`) key, a heuristic for a color theme.
|
|
16
|
+
//
|
|
17
|
+
// The walk visits entries in name order, never follows symlinks, skips
|
|
18
|
+
// dependency, build and VCS directories, every dot-directory and the root
|
|
19
|
+
// design/ (the sync's own output), enters at most maxDepth directory levels,
|
|
20
|
+
// stops after maxFiles files, and reads at most maxBytes of each file.
|
|
21
|
+
|
|
22
|
+
export const DESIGN_SOURCE_LIMITS = { maxDepth: 6, maxFiles: 5000, maxBytes: 256 * 1024 } as const;
|
|
23
|
+
|
|
24
|
+
const SKIPPED_DIRS = new Set(["node_modules", "dist", "build", "out", "coverage"]);
|
|
25
|
+
const TAILWIND_CONFIG = /^tailwind\.config\.(?:js|cjs|mjs|ts)$/;
|
|
26
|
+
// "tokens" as a whole word of the base name (tokens.json, design-tokens.ts,
|
|
27
|
+
// theme.tokens.css), never auth or tokenizer files (token.ts, refreshToken.ts)
|
|
28
|
+
// and never tests.
|
|
29
|
+
const TOKEN_FILE = /^(?:.*[.\-_])?tokens(?:[.\-_].*)?\.(?:json|js|ts|css)$/i;
|
|
30
|
+
const TEST_FILE = /\.(?:test|spec)\.[^.]+$/i;
|
|
31
|
+
const STYLESHEET = /\.(?:css|scss|pcss)$/;
|
|
32
|
+
const THEME_FILE = /^theme\.(?:js|ts)$/;
|
|
33
|
+
const CUSTOM_PROPERTY_IN_THEME_BLOCK = /(?::root|\[data-theme[^\]]*\])[^{};]*\{[^}]*--[\w-]+\s*:/;
|
|
34
|
+
|
|
35
|
+
// One read buffer for the whole synchronous walk; readHead copies out of it.
|
|
36
|
+
const HEAD = Buffer.allocUnsafe(DESIGN_SOURCE_LIMITS.maxBytes);
|
|
37
|
+
|
|
38
|
+
function readHead(path: string): string {
|
|
39
|
+
const fd = openSync(path, "r");
|
|
40
|
+
try {
|
|
41
|
+
const buffer = HEAD;
|
|
42
|
+
let length = 0;
|
|
43
|
+
while (length < buffer.length) {
|
|
44
|
+
const read = readSync(fd, buffer, length, buffer.length - length, length);
|
|
45
|
+
if (read === 0) break;
|
|
46
|
+
length += read;
|
|
47
|
+
}
|
|
48
|
+
return buffer.toString("utf8", 0, length);
|
|
49
|
+
} finally {
|
|
50
|
+
closeSync(fd);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isDesignSource(name: string, path: string): boolean {
|
|
55
|
+
if (TAILWIND_CONFIG.test(name) || (TOKEN_FILE.test(name) && !TEST_FILE.test(name))) return true;
|
|
56
|
+
if (STYLESHEET.test(name)) {
|
|
57
|
+
const css = readHead(path).replace(/\/\*[\s\S]*?\*\//g, "");
|
|
58
|
+
return css.includes("@theme") || CUSTOM_PROPERTY_IN_THEME_BLOCK.test(css);
|
|
59
|
+
}
|
|
60
|
+
if (THEME_FILE.test(name)) {
|
|
61
|
+
const source = readHead(path);
|
|
62
|
+
return /\bexport\b|module\.exports/.test(source) && /\bcolou?rs\s*[:=]/.test(source);
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function entries(dir: string): Dirent[] {
|
|
68
|
+
try {
|
|
69
|
+
return readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
70
|
+
} catch {
|
|
71
|
+
// An unreadable or vanished directory has no sources.
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Repository-relative paths (with "/") of the project's design sources, sorted. */
|
|
77
|
+
export function detectDesignSources(cwd: string): string[] {
|
|
78
|
+
const found: string[] = [];
|
|
79
|
+
let files = 0;
|
|
80
|
+
const walk = (dir: string, relative: string, depth: number): boolean => {
|
|
81
|
+
for (const entry of entries(dir)) {
|
|
82
|
+
// Dirent types come from lstat, so a symlink is neither a file nor a directory here.
|
|
83
|
+
const path = join(dir, entry.name);
|
|
84
|
+
const rel = relative ? `${relative}/${entry.name}` : entry.name;
|
|
85
|
+
if (entry.isDirectory()) {
|
|
86
|
+
if (entry.name.startsWith(".") || SKIPPED_DIRS.has(entry.name) || (depth === 0 && entry.name === "design")) continue;
|
|
87
|
+
if (depth + 1 > DESIGN_SOURCE_LIMITS.maxDepth) continue;
|
|
88
|
+
if (!walk(path, rel, depth + 1)) return false;
|
|
89
|
+
} else if (entry.isFile()) {
|
|
90
|
+
if (++files > DESIGN_SOURCE_LIMITS.maxFiles) return false;
|
|
91
|
+
try {
|
|
92
|
+
if (isDesignSource(entry.name, path)) found.push(rel);
|
|
93
|
+
} catch {
|
|
94
|
+
// A file that cannot be read is not a source.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return true;
|
|
99
|
+
};
|
|
100
|
+
walk(cwd, "", 0);
|
|
101
|
+
return found.sort();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function mtime(path: string): number | undefined {
|
|
105
|
+
try {
|
|
106
|
+
return statSync(path).mtimeMs;
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The sources modified after design/system.md; none when that file is missing. */
|
|
113
|
+
export function staleDesignSources(cwd: string, sources: readonly string[]): string[] {
|
|
114
|
+
const system = mtime(join(cwd, "design", "system.md"));
|
|
115
|
+
if (system === undefined) return [];
|
|
116
|
+
return sources.filter((source) => {
|
|
117
|
+
const changed = mtime(join(cwd, source));
|
|
118
|
+
return changed !== undefined && changed > system;
|
|
119
|
+
});
|
|
120
|
+
}
|