@planu/cli 4.8.0 → 4.10.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/config/license-plans.json +5 -0
  3. package/dist/config/project-knowledge-graph.json +70 -2
  4. package/dist/config/registries/hosts/codex.json +3 -4
  5. package/dist/engine/evidence-index/index-builder.js +38 -12
  6. package/dist/engine/project-graph/builder.js +9 -5
  7. package/dist/engine/project-graph/cache.js +4 -0
  8. package/dist/engine/project-graph/extractors/decision-store-extractor.d.ts +3 -0
  9. package/dist/engine/project-graph/extractors/decision-store-extractor.js +57 -0
  10. package/dist/engine/structural-memory/architecture-snapshot.d.ts +3 -0
  11. package/dist/engine/structural-memory/architecture-snapshot.js +132 -0
  12. package/dist/engine/structural-memory/artifact-policy.d.ts +5 -0
  13. package/dist/engine/structural-memory/artifact-policy.js +17 -0
  14. package/dist/engine/structural-memory/change-impact.d.ts +3 -0
  15. package/dist/engine/structural-memory/change-impact.js +159 -0
  16. package/dist/engine/structural-memory/contracts.d.ts +2 -0
  17. package/dist/engine/structural-memory/contracts.js +2 -0
  18. package/dist/engine/structural-memory/helpers.d.ts +28 -0
  19. package/dist/engine/structural-memory/helpers.js +122 -0
  20. package/dist/engine/structural-memory/index.d.ts +7 -0
  21. package/dist/engine/structural-memory/index.js +6 -0
  22. package/dist/engine/structural-memory/query.d.ts +3 -0
  23. package/dist/engine/structural-memory/query.js +67 -0
  24. package/dist/engine/structural-memory/status.d.ts +3 -0
  25. package/dist/engine/structural-memory/status.js +30 -0
  26. package/dist/storage/feedback-remote.d.ts +5 -1
  27. package/dist/storage/feedback-remote.js +75 -16
  28. package/dist/storage/feedback-store.d.ts +3 -1
  29. package/dist/storage/feedback-store.js +107 -10
  30. package/dist/tools/feedback-handler.d.ts +3 -1
  31. package/dist/tools/feedback-handler.js +47 -1
  32. package/dist/tools/register-sdd-tools.d.ts +1 -1
  33. package/dist/tools/register-sdd-tools.js +5 -0
  34. package/dist/tools/status-handler.js +11 -11
  35. package/dist/tools/tool-registry/core-tools.js +97 -0
  36. package/dist/tools/tool-registry.js +3 -0
  37. package/dist/tools/validate-assurance.d.ts +6 -0
  38. package/dist/tools/validate-assurance.js +55 -0
  39. package/dist/tools/validate-graph-coverage.d.ts +8 -0
  40. package/dist/tools/validate-graph-coverage.js +30 -0
  41. package/dist/tools/validate-helpers.d.ts +9 -0
  42. package/dist/tools/validate-helpers.js +47 -0
  43. package/dist/tools/validate-lint.d.ts +3 -0
  44. package/dist/tools/validate-lint.js +67 -0
  45. package/dist/tools/validate-minimality.d.ts +16 -0
  46. package/dist/tools/validate-minimality.js +22 -0
  47. package/dist/tools/validate-runtime.d.ts +14 -0
  48. package/dist/tools/validate-runtime.js +85 -0
  49. package/dist/tools/validate.js +5 -222
  50. package/dist/types/evidence-index.d.ts +1 -0
  51. package/dist/types/feedback.d.ts +37 -0
  52. package/dist/types/index.d.ts +2 -0
  53. package/dist/types/index.js +2 -0
  54. package/dist/types/project-knowledge-graph.d.ts +10 -0
  55. package/dist/types/structural-memory.d.ts +138 -0
  56. package/dist/types/structural-memory.js +2 -0
  57. package/package.json +20 -20
  58. package/planu-native.json +1 -1
  59. package/planu-plugin.json +1 -1
@@ -0,0 +1,159 @@
1
+ import { execFile as execFileCb } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { clampLimit, loadFreshGraph, nextActionForFreshness, pagination, resolveProjectId, safeRelativeProjectPath, structuralPolicy, } from './helpers.js';
4
+ const execFile = promisify(execFileCb);
5
+ function safeGitRef(ref) {
6
+ return (/^[A-Za-z0-9][A-Za-z0-9._/@{}:+-]{0,200}$/.test(ref) &&
7
+ !ref.includes('..') &&
8
+ !ref.startsWith('-'));
9
+ }
10
+ function statusFromCode(code) {
11
+ if (code.includes('A')) {
12
+ return 'added';
13
+ }
14
+ if (code.includes('D')) {
15
+ return 'deleted';
16
+ }
17
+ if (code.includes('R')) {
18
+ return 'renamed';
19
+ }
20
+ if (code.includes('?')) {
21
+ return 'untracked';
22
+ }
23
+ if (code.trim().length > 0) {
24
+ return 'modified';
25
+ }
26
+ return 'unknown';
27
+ }
28
+ function parseNameStatus(stdout, projectPath) {
29
+ return stdout
30
+ .split('\n')
31
+ .map((line) => line.trimEnd())
32
+ .filter((line) => line.length > 0)
33
+ .flatMap((line) => {
34
+ const [status, ...paths] = line.split(/\s+/);
35
+ const path = paths.at(-1);
36
+ if (!status || !path) {
37
+ return [];
38
+ }
39
+ const safePath = safeRelativeProjectPath(projectPath, path);
40
+ return safePath === null ? [] : [{ path: safePath, status: statusFromCode(status) }];
41
+ });
42
+ }
43
+ function parsePorcelain(stdout, projectPath, includeUntracked) {
44
+ return stdout
45
+ .split('\n')
46
+ .map((line) => line.trimEnd())
47
+ .filter((line) => line.length > 0)
48
+ .flatMap((line) => {
49
+ const code = line.slice(0, 2);
50
+ if (!includeUntracked && code === '??') {
51
+ return [];
52
+ }
53
+ const rawPath = line.slice(3).split(' -> ').at(-1);
54
+ if (!rawPath) {
55
+ return [];
56
+ }
57
+ const safePath = safeRelativeProjectPath(projectPath, rawPath);
58
+ return safePath === null ? [] : [{ path: safePath, status: statusFromCode(code) }];
59
+ });
60
+ }
61
+ async function gitChangedFiles(input) {
62
+ if (input.changedFiles !== undefined) {
63
+ return input.changedFiles.flatMap((path) => {
64
+ const safePath = safeRelativeProjectPath(input.projectPath, path);
65
+ return safePath === null ? [] : [{ path: safePath, status: 'unknown' }];
66
+ });
67
+ }
68
+ if (input.baseRef !== undefined) {
69
+ if (!safeGitRef(input.baseRef)) {
70
+ throw new Error('Unsafe git baseRef for structural change impact.');
71
+ }
72
+ const { stdout } = await execFile('git', ['diff', '--name-status', input.baseRef, '--'], {
73
+ cwd: input.projectPath,
74
+ maxBuffer: 1024 * 1024,
75
+ });
76
+ return parseNameStatus(stdout, input.projectPath);
77
+ }
78
+ const { stdout } = await execFile('git', ['status', '--porcelain'], {
79
+ cwd: input.projectPath,
80
+ maxBuffer: 1024 * 1024,
81
+ });
82
+ return parsePorcelain(stdout, input.projectPath, input.includeUntracked ?? true);
83
+ }
84
+ function uniqueNodes(nodes) {
85
+ return [...new Map(nodes.map((node) => [node.id, node])).values()];
86
+ }
87
+ function collectAffected(graphNodes, graphEdges, changedFiles) {
88
+ const changed = new Set(changedFiles.map((file) => file.path));
89
+ const fileNodeIds = new Set(graphNodes
90
+ .filter((node) => node.type === 'file' && changed.has(node.label))
91
+ .map((node) => node.id));
92
+ const affectedIds = new Set(fileNodeIds);
93
+ for (let depth = 0; depth < 3; depth += 1) {
94
+ for (const edge of graphEdges) {
95
+ if (affectedIds.has(edge.from)) {
96
+ affectedIds.add(edge.to);
97
+ }
98
+ if (affectedIds.has(edge.to)) {
99
+ affectedIds.add(edge.from);
100
+ }
101
+ }
102
+ }
103
+ const nodes = graphNodes.filter((node) => affectedIds.has(node.id));
104
+ const ids = new Set(nodes.map((node) => node.id));
105
+ const edges = graphEdges.filter((edge) => ids.has(edge.from) && ids.has(edge.to));
106
+ return { nodes, edges };
107
+ }
108
+ export async function detectStructuralChangeImpact(input) {
109
+ const policy = await structuralPolicy();
110
+ const projectId = resolveProjectId(input);
111
+ const limit = clampLimit(input.limit, policy);
112
+ const changedFiles = await gitChangedFiles(input);
113
+ const { graph, freshness } = await loadFreshGraph({
114
+ projectId,
115
+ projectPath: input.projectPath,
116
+ policy,
117
+ });
118
+ const page = pagination(changedFiles.length, limit, 0);
119
+ if (graph === null) {
120
+ return {
121
+ freshness,
122
+ pagination: page,
123
+ ...page,
124
+ nextAction: nextActionForFreshness(freshness, true),
125
+ changedFiles: changedFiles.slice(0, limit),
126
+ affected: {
127
+ specs: [],
128
+ criteria: [],
129
+ tests: [],
130
+ decisions: [],
131
+ evidence: [],
132
+ risks: [],
133
+ releaseRisk: [],
134
+ },
135
+ affectedEdges: [],
136
+ };
137
+ }
138
+ const affected = collectAffected(graph.nodes, graph.edges, changedFiles);
139
+ const byType = (types) => uniqueNodes(affected.nodes.filter((node) => types.includes(node.type))).slice(0, limit);
140
+ return {
141
+ graphVersion: graph.graphVersion,
142
+ freshness,
143
+ pagination: page,
144
+ ...page,
145
+ nextAction: nextActionForFreshness(freshness, affected.nodes.length === 0),
146
+ changedFiles: changedFiles.slice(0, limit),
147
+ affected: {
148
+ specs: byType(['spec']),
149
+ criteria: byType(['criterion']),
150
+ tests: byType(['test']),
151
+ decisions: byType(['decision', 'adr']),
152
+ evidence: byType(['handoff', 'validation', 'validation_evidence', 'contract']),
153
+ risks: byType(['risk', 'architecture_risk', 'drift']),
154
+ releaseRisk: byType(['release', 'release_risk']),
155
+ },
156
+ affectedEdges: affected.edges.slice(0, policy.query.maxEdges),
157
+ };
158
+ }
159
+ //# sourceMappingURL=change-impact.js.map
@@ -0,0 +1,2 @@
1
+ export type * from '../../types/structural-memory.js';
2
+ //# sourceMappingURL=contracts.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=contracts.js.map
@@ -0,0 +1,28 @@
1
+ import type { ProjectGraphEdge, ProjectGraphFreshness, ProjectGraphNode, ProjectGraphPolicy } from '../../types/project-knowledge-graph.js';
2
+ import { readProjectGraph } from '../project-graph/cache.js';
3
+ import type { StructuralMemoryFreshness, StructuralMemoryNextAction, StructuralMemoryPagination } from './contracts.js';
4
+ export declare function resolveProjectId(input: {
5
+ projectId?: string;
6
+ projectPath: string;
7
+ }): string;
8
+ export declare function freshnessStatus(freshness: ProjectGraphFreshness): StructuralMemoryFreshness;
9
+ export declare function nextActionForFreshness(freshness: StructuralMemoryFreshness, empty?: boolean): StructuralMemoryNextAction;
10
+ export declare function pagination(total: number, limit: number, offset: number): StructuralMemoryPagination;
11
+ export declare function clampLimit(limit: number | undefined, policy: ProjectGraphPolicy): number;
12
+ export declare function safeRelativeProjectPath(projectPath: string, candidate: string): string | null;
13
+ export declare function loadFreshGraph(args: {
14
+ projectId: string;
15
+ projectPath: string;
16
+ policy: ProjectGraphPolicy;
17
+ }): Promise<{
18
+ graph: Awaited<ReturnType<typeof readProjectGraph>>;
19
+ freshness: StructuralMemoryFreshness;
20
+ }>;
21
+ export declare function summarizeNodes(nodes: ProjectGraphNode[]): Record<string, number>;
22
+ export declare function relatedNodeIds(nodes: ProjectGraphNode[], edges: ProjectGraphEdge[], input: {
23
+ specId?: string;
24
+ filePath?: string;
25
+ nodeId?: string;
26
+ }): Set<string>;
27
+ export declare function structuralPolicy(): Promise<ProjectGraphPolicy>;
28
+ //# sourceMappingURL=helpers.d.ts.map
@@ -0,0 +1,122 @@
1
+ import { isAbsolute, normalize, relative, resolve } from 'node:path';
2
+ import { hashProjectPath } from '../../storage/base-store.js';
3
+ import { getProjectGraphFreshness, loadProjectGraphPolicy, readProjectGraph, } from '../project-graph/cache.js';
4
+ export function resolveProjectId(input) {
5
+ return input.projectId ?? hashProjectPath(input.projectPath);
6
+ }
7
+ export function freshnessStatus(freshness) {
8
+ const status = freshness.reason === 'missing'
9
+ ? 'missing'
10
+ : freshness.reason === 'corrupt'
11
+ ? 'corrupt'
12
+ : freshness.stale
13
+ ? 'stale'
14
+ : 'fresh';
15
+ return {
16
+ status,
17
+ graphPath: freshness.graphPath,
18
+ generatedAt: freshness.generatedAt,
19
+ changedSources: freshness.changedSources,
20
+ reason: freshness.reason,
21
+ };
22
+ }
23
+ export function nextActionForFreshness(freshness, empty = false) {
24
+ if (freshness.status === 'missing' || freshness.status === 'corrupt') {
25
+ return {
26
+ type: 'rebuild_graph',
27
+ message: 'Rebuild the project graph before trusting structural memory results.',
28
+ };
29
+ }
30
+ if (freshness.status === 'stale' || freshness.status === 'partial') {
31
+ return {
32
+ type: 'run_validate',
33
+ message: 'Run validation or rebuild graph context before using this result as gate evidence.',
34
+ };
35
+ }
36
+ if (empty) {
37
+ return {
38
+ type: 'broaden_filters',
39
+ message: 'No structural memory nodes matched; broaden filters or inspect evidence coverage.',
40
+ };
41
+ }
42
+ return { type: 'none', message: 'Structural memory result is ready to use.' };
43
+ }
44
+ export function pagination(total, limit, offset) {
45
+ const hasMore = offset + limit < total;
46
+ return {
47
+ total,
48
+ limit,
49
+ offset,
50
+ hasMore,
51
+ has_more: hasMore,
52
+ truncated: hasMore,
53
+ };
54
+ }
55
+ export function clampLimit(limit, policy) {
56
+ const defaultLimit = policy.structuralMemory?.response.defaultLimit ?? policy.query.maxNodes;
57
+ const maxLimit = policy.structuralMemory?.response.maxLimit ?? policy.query.maxNodes;
58
+ return Math.max(1, Math.min(limit ?? defaultLimit, maxLimit));
59
+ }
60
+ export function safeRelativeProjectPath(projectPath, candidate) {
61
+ if (candidate.trim().length === 0 || isAbsolute(candidate) || candidate.includes('\0')) {
62
+ return null;
63
+ }
64
+ const normalized = normalize(candidate).replace(/\\/g, '/');
65
+ if (normalized === '..' || normalized.startsWith('../') || normalized.includes('/../')) {
66
+ return null;
67
+ }
68
+ const root = resolve(projectPath);
69
+ const absolute = resolve(root, normalized);
70
+ const rel = relative(root, absolute).replace(/\\/g, '/');
71
+ if (rel === '..' || rel.startsWith('../') || isAbsolute(rel)) {
72
+ return null;
73
+ }
74
+ return rel;
75
+ }
76
+ export async function loadFreshGraph(args) {
77
+ const rawFreshness = await getProjectGraphFreshness(args);
78
+ return {
79
+ graph: await readProjectGraph(args.projectId, args.policy),
80
+ freshness: freshnessStatus(rawFreshness),
81
+ };
82
+ }
83
+ export function summarizeNodes(nodes) {
84
+ return nodes.reduce((acc, node) => {
85
+ return { ...acc, [node.type]: (acc[node.type] ?? 0) + 1 };
86
+ }, {});
87
+ }
88
+ export function relatedNodeIds(nodes, edges, input) {
89
+ const seeds = new Set();
90
+ for (const node of nodes) {
91
+ const specMatch = input.specId !== undefined &&
92
+ (node.id === `spec:${input.specId}` || node.metadata?.specId === input.specId);
93
+ const fileMatch = input.filePath !== undefined &&
94
+ (node.id === `file:${input.filePath}` || node.label === input.filePath);
95
+ const nodeMatch = input.nodeId !== undefined && node.id === input.nodeId;
96
+ if (specMatch || fileMatch || nodeMatch) {
97
+ seeds.add(node.id);
98
+ }
99
+ }
100
+ if (seeds.size === 0 &&
101
+ input.specId === undefined &&
102
+ input.filePath === undefined &&
103
+ input.nodeId === undefined) {
104
+ for (const node of nodes) {
105
+ seeds.add(node.id);
106
+ }
107
+ }
108
+ const related = new Set(seeds);
109
+ for (const edge of edges) {
110
+ if (seeds.has(edge.from)) {
111
+ related.add(edge.to);
112
+ }
113
+ if (seeds.has(edge.to)) {
114
+ related.add(edge.from);
115
+ }
116
+ }
117
+ return related;
118
+ }
119
+ export async function structuralPolicy() {
120
+ return loadProjectGraphPolicy();
121
+ }
122
+ //# sourceMappingURL=helpers.js.map
@@ -0,0 +1,7 @@
1
+ export type * from './contracts.js';
2
+ export { queryStructuralMemory } from './query.js';
3
+ export { detectStructuralChangeImpact } from './change-impact.js';
4
+ export { getArchitectureSnapshot } from './architecture-snapshot.js';
5
+ export { getStructuralMemoryStatus } from './status.js';
6
+ export { createStructuralMemoryArtifactPolicy, isStructuralMemoryArtifactPathAllowed, } from './artifact-policy.js';
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,6 @@
1
+ export { queryStructuralMemory } from './query.js';
2
+ export { detectStructuralChangeImpact } from './change-impact.js';
3
+ export { getArchitectureSnapshot } from './architecture-snapshot.js';
4
+ export { getStructuralMemoryStatus } from './status.js';
5
+ export { createStructuralMemoryArtifactPolicy, isStructuralMemoryArtifactPathAllowed, } from './artifact-policy.js';
6
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,3 @@
1
+ import type { StructuralMemoryQueryInput, StructuralMemoryQueryResult } from './contracts.js';
2
+ export declare function queryStructuralMemory(input: StructuralMemoryQueryInput): Promise<StructuralMemoryQueryResult>;
3
+ //# sourceMappingURL=query.d.ts.map
@@ -0,0 +1,67 @@
1
+ import { clampLimit, loadFreshGraph, nextActionForFreshness, pagination, relatedNodeIds, resolveProjectId, safeRelativeProjectPath, structuralPolicy, summarizeNodes, } from './helpers.js';
2
+ export async function queryStructuralMemory(input) {
3
+ const policy = await structuralPolicy();
4
+ const projectId = resolveProjectId(input);
5
+ const filePath = input.filePath === undefined
6
+ ? undefined
7
+ : safeRelativeProjectPath(input.projectPath, input.filePath);
8
+ const freshnessGraph = await loadFreshGraph({
9
+ projectId,
10
+ projectPath: input.projectPath,
11
+ policy,
12
+ });
13
+ const limit = clampLimit(input.limit, policy);
14
+ const offset = Math.max(0, input.offset ?? 0);
15
+ if (input.filePath !== undefined && filePath === null) {
16
+ const page = pagination(0, limit, offset);
17
+ return {
18
+ freshness: freshnessGraph.freshness,
19
+ pagination: page,
20
+ ...page,
21
+ nextAction: {
22
+ type: 'inspect_evidence',
23
+ message: 'Rejecting unsafe filePath filter; pass a project-relative path inside the repo.',
24
+ },
25
+ nodes: [],
26
+ edges: [],
27
+ summary: {},
28
+ };
29
+ }
30
+ const graph = freshnessGraph.graph;
31
+ if (graph === null) {
32
+ const page = pagination(0, limit, offset);
33
+ return {
34
+ freshness: freshnessGraph.freshness,
35
+ pagination: page,
36
+ ...page,
37
+ nextAction: nextActionForFreshness(freshnessGraph.freshness, true),
38
+ nodes: [],
39
+ edges: [],
40
+ summary: {},
41
+ };
42
+ }
43
+ const ids = relatedNodeIds(graph.nodes, graph.edges, {
44
+ specId: input.specId,
45
+ nodeId: input.nodeId,
46
+ filePath: filePath ?? undefined,
47
+ });
48
+ const typeFilter = new Set(input.nodeTypes ?? []);
49
+ const filteredNodes = graph.nodes.filter((node) => ids.has(node.id) && (typeFilter.size === 0 || typeFilter.has(node.type)));
50
+ const nodePage = filteredNodes.slice(offset, offset + limit);
51
+ const nodeIds = new Set(nodePage.map((node) => node.id));
52
+ const edges = graph.edges.filter((edge) => nodeIds.has(edge.from) && nodeIds.has(edge.to));
53
+ const page = pagination(filteredNodes.length, limit, offset);
54
+ return {
55
+ graphVersion: graph.graphVersion,
56
+ freshness: freshnessGraph.freshness,
57
+ pagination: page,
58
+ ...page,
59
+ nextAction: nextActionForFreshness(freshnessGraph.freshness, filteredNodes.length === 0),
60
+ nodes: nodePage,
61
+ edges,
62
+ summary: summarizeNodes(nodePage),
63
+ sourceHash: graph.sourceHash,
64
+ sourceHashes: graph.sourceHashes,
65
+ };
66
+ }
67
+ //# sourceMappingURL=query.js.map
@@ -0,0 +1,3 @@
1
+ import type { StructuralMemoryStatusInput, StructuralMemoryStatusResult } from './contracts.js';
2
+ export declare function getStructuralMemoryStatus(input: StructuralMemoryStatusInput): Promise<StructuralMemoryStatusResult>;
3
+ //# sourceMappingURL=status.d.ts.map
@@ -0,0 +1,30 @@
1
+ import { createStructuralMemoryArtifactPolicy, isStructuralMemoryArtifactPathAllowed, } from './artifact-policy.js';
2
+ import { loadFreshGraph, nextActionForFreshness, resolveProjectId, structuralPolicy, } from './helpers.js';
3
+ export async function getStructuralMemoryStatus(input) {
4
+ const policy = await structuralPolicy();
5
+ const projectId = resolveProjectId(input);
6
+ const { graph, freshness } = await loadFreshGraph({
7
+ projectId,
8
+ projectPath: input.projectPath,
9
+ policy,
10
+ });
11
+ const artifactPolicy = createStructuralMemoryArtifactPolicy(projectId, policy);
12
+ if (!isStructuralMemoryArtifactPathAllowed(artifactPolicy.graphPath)) {
13
+ throw new Error('Structural memory graph artifacts must remain outside planu/specs.');
14
+ }
15
+ return {
16
+ freshness,
17
+ nextAction: nextActionForFreshness(freshness, graph === null),
18
+ artifactPolicy,
19
+ graph: graph === null
20
+ ? undefined
21
+ : {
22
+ graphVersion: graph.graphVersion,
23
+ generatedAt: graph.generatedAt,
24
+ sourceHash: graph.sourceHash,
25
+ counts: graph.metadata,
26
+ sourceHashCount: Object.keys(graph.sourceHashes).length,
27
+ },
28
+ };
29
+ }
30
+ //# sourceMappingURL=status.js.map
@@ -1,5 +1,9 @@
1
- import type { RemoteFeedbackPayload } from '../types/feedback.js';
1
+ import type { FeedbackRemoteResult, RemoteFeedbackPayload } from '../types/feedback.js';
2
+ export declare const DEFAULT_FEEDBACK_API = "https://canusqigtzldstnjddio.supabase.co/functions/v1/license-api/feedback";
2
3
  export type { RemoteFeedbackPayload };
4
+ export declare function resolveFeedbackEndpoint(endpoint?: string): string;
5
+ export declare function sanitizeRemoteFeedbackPayload(payload: RemoteFeedbackPayload): RemoteFeedbackPayload;
6
+ export declare function deliverFeedbackRemote(payload: RemoteFeedbackPayload, endpoint?: string): Promise<FeedbackRemoteResult>;
3
7
  /**
4
8
  * Send feedback to central Supabase store (fire-and-forget).
5
9
  * Never throws. Returns true if sent successfully, false otherwise.
@@ -1,27 +1,86 @@
1
1
  // storage/feedback-remote.ts — Remote feedback relay to Supabase (fire-and-forget)
2
2
  // No PII is ever sent: no project paths, no user names, no emails.
3
- const FEEDBACK_API = 'https://canusqigtzldstnjddio.supabase.co/functions/v1/license-api/feedback';
4
- /**
5
- * Send feedback to central Supabase store (fire-and-forget).
6
- * Never throws. Returns true if sent successfully, false otherwise.
7
- */
8
- export async function sendFeedbackRemote(payload) {
3
+ export const DEFAULT_FEEDBACK_API = 'https://canusqigtzldstnjddio.supabase.co/functions/v1/license-api/feedback';
4
+ export function resolveFeedbackEndpoint(endpoint) {
5
+ return endpoint ?? process.env.PLANU_FEEDBACK_ENDPOINT ?? DEFAULT_FEEDBACK_API;
6
+ }
7
+ function redactSensitiveText(value) {
8
+ return value
9
+ .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[redacted-email]')
10
+ .replace(/(?:\/Users|\/home|\/var|\/tmp|[A-Za-z]:\\)[^\s)'"`]+/g, '[redacted-path]')
11
+ .replace(/```[\s\S]*?```/g, '[redacted-code-block]')
12
+ .replace(/\b(?:const|let|var|function|class|import|export)\s+[^\n]+/g, '[redacted-code]');
13
+ }
14
+ export function sanitizeRemoteFeedbackPayload(payload) {
15
+ return {
16
+ ...payload,
17
+ title: redactSensitiveText(payload.title),
18
+ description: redactSensitiveText(payload.description),
19
+ ...(payload.stack !== undefined ? { stack: redactSensitiveText(payload.stack) } : {}),
20
+ };
21
+ }
22
+ export async function deliverFeedbackRemote(payload, endpoint) {
23
+ const resolvedEndpoint = resolveFeedbackEndpoint(endpoint);
24
+ let url;
9
25
  try {
10
- const controller = new AbortController();
11
- const timeout = setTimeout(() => {
12
- controller.abort();
13
- }, 5000); // 5s timeout
14
- const res = await fetch(FEEDBACK_API, {
26
+ url = new URL(resolvedEndpoint);
27
+ }
28
+ catch {
29
+ return {
30
+ ok: false,
31
+ status: 'skipped',
32
+ endpoint: resolvedEndpoint,
33
+ error: 'Invalid feedback endpoint URL.',
34
+ };
35
+ }
36
+ if (url.protocol !== 'https:' && url.hostname !== 'localhost' && url.hostname !== '127.0.0.1') {
37
+ return {
38
+ ok: false,
39
+ status: 'skipped',
40
+ endpoint: resolvedEndpoint,
41
+ error: 'Feedback endpoint must use HTTPS.',
42
+ };
43
+ }
44
+ const controller = new AbortController();
45
+ const timeout = setTimeout(() => {
46
+ controller.abort();
47
+ }, 5000);
48
+ try {
49
+ const res = await fetch(resolvedEndpoint, {
15
50
  method: 'POST',
16
51
  headers: { 'Content-Type': 'application/json' },
17
- body: JSON.stringify(payload),
52
+ body: JSON.stringify(sanitizeRemoteFeedbackPayload(payload)),
18
53
  signal: controller.signal,
19
54
  });
20
- clearTimeout(timeout);
21
- return res.ok;
55
+ if (res.ok) {
56
+ return { ok: true, status: 'synced', endpoint: resolvedEndpoint };
57
+ }
58
+ return {
59
+ ok: false,
60
+ status: 'failed',
61
+ endpoint: resolvedEndpoint,
62
+ error: `Remote feedback endpoint returned HTTP ${res.status}.`,
63
+ };
22
64
  }
23
- catch {
24
- return false; // Network error, timeout, etc. silently fail
65
+ catch (err) {
66
+ const message = err instanceof Error ? err.message : String(err);
67
+ return {
68
+ ok: false,
69
+ status: 'failed',
70
+ endpoint: resolvedEndpoint,
71
+ error: message || 'Remote feedback request failed.',
72
+ };
25
73
  }
74
+ finally {
75
+ clearTimeout(timeout);
76
+ }
77
+ }
78
+ /**
79
+ * Send feedback to central Supabase store (fire-and-forget).
80
+ * Never throws. Returns true if sent successfully, false otherwise.
81
+ */
82
+ export async function sendFeedbackRemote(payload) {
83
+ const result = await deliverFeedbackRemote(payload);
84
+ return result.ok;
26
85
  }
27
86
  //# sourceMappingURL=feedback-remote.js.map
@@ -1,4 +1,4 @@
1
- import type { FeedbackEntry, FeedbackFilter, FeedbackSummary } from '../types/index.js';
1
+ import type { FeedbackEntry, FeedbackFilter, FeedbackSummary, FeedbackRemoteHealth, SyncFeedbackInput, SyncFeedbackSummary } from '../types/index.js';
2
2
  /**
3
3
  * Submit a new feedback entry. Auto-generates ID, timestamp, and version.
4
4
  */
@@ -19,6 +19,8 @@ export declare function resolveFeedback(dataDir: string, id: string, specId?: st
19
19
  * Compute summary statistics across all feedback.
20
20
  */
21
21
  export declare function getSummary(dataDir: string): FeedbackSummary;
22
+ export declare function getFeedbackRemoteHealth(dataDir: string): FeedbackRemoteHealth;
23
+ export declare function syncFeedbackRemote(dataDir: string, input?: SyncFeedbackInput): Promise<SyncFeedbackSummary>;
22
24
  /**
23
25
  * Find feedback entries with title similar to the given string.
24
26
  * Uses Jaccard word overlap. Threshold defaults to 0.3.