@ijfw/memory-server 1.4.3 → 1.5.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/fixtures/truncation-corpus/_generate-corpus.js +367 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/snapshots/v-midO-1-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/snapshots/v-midO-2-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/snapshots/v-midO-3-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/snapshots/v-midO-4-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/snapshots/v-midO-5-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/snapshots/v-noEv-1-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/snapshots/v-noEv-2-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/snapshots/v-noEv-3-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/snapshots/v-noEv-4-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/snapshots/v-noEv-5-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/snapshots/v-errT-1-partial.json +11 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/snapshots/v-errT-3-partial.json +11 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/snapshots/v-errT-5-partial.json +11 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/target/.ijfw/state/workflow.json +1 -0
- package/package.json +1 -1
- package/src/active-extension-writer.js +144 -64
- package/src/api-client.js +43 -5
- package/src/audit-roster.js +80 -5
- package/src/blackboard.js +298 -6
- package/src/cli-run.js +33 -5
- package/src/codex-agents.js +96 -5
- package/src/cost/aggregator.js +39 -9
- package/src/cost/pricing.js +57 -0
- package/src/cost/readers/gemini.js +1 -1
- package/src/cross-audit-chunker.js +189 -0
- package/src/cross-dispatcher.js +124 -21
- package/src/cross-orchestrator-cli.js +550 -14
- package/src/cross-orchestrator.js +1171 -10
- package/src/cross-project-search.js +195 -9
- package/src/dashboard-client-planning.html +273 -0
- package/src/dashboard-client-waves.html +304 -0
- package/src/dashboard-client.html +17 -2
- package/src/dashboard-server.js +152 -0
- package/src/deploy-alerts.js +150 -0
- package/src/design/iframe-bridge.js +242 -0
- package/src/design-companion.js +144 -0
- package/src/dispatch/checkpoint-cli.js +97 -0
- package/src/dispatch/colon-syntax.js +81 -1
- package/src/dispatch/extension.js +27 -1
- package/src/dispatch/registry-cli.js +4 -1
- package/src/dispatch/wave-cli.js +323 -0
- package/src/dispatch/worktree-cli.js +40 -0
- package/src/dispatch-planner.js +97 -2
- package/src/dream/runner.mjs +47 -11
- package/src/dream/stage-runner.js +40 -0
- package/src/dream/state-file.js +102 -0
- package/src/extension-installer.js +70 -24
- package/src/extension-quota-tracker.js +4 -2
- package/src/extension-registry.js +289 -35
- package/src/feedback-detector.js +26 -0
- package/src/fs-lock.js +259 -7
- package/src/gate-result.js +95 -1
- package/src/hero-line.js +86 -5
- package/src/intent-router.js +35 -0
- package/src/lib/a11y-contract.js +117 -0
- package/src/lib/atomic-io.js +29 -8
- package/src/lib/cache-keepalive.js +150 -0
- package/src/lib/jsonl-rotation.js +104 -0
- package/src/lib/lighthouse-pillar.js +121 -0
- package/src/lib/llm-call.js +121 -0
- package/src/lib/playwright-baseline.js +205 -0
- package/src/lib/rekor-bridge.js +221 -0
- package/src/lib/repo-map.js +392 -0
- package/src/lib/shasum-verify.js +164 -0
- package/src/lib/sketches-gc.js +132 -0
- package/src/lib/tmp-suffix.js +62 -0
- package/src/lib/ui-review-runner.js +554 -0
- package/src/lib/uispec-drift.js +301 -0
- package/src/lib/uispec-intake.js +381 -0
- package/src/lib/worktree-guards.js +118 -0
- package/src/lib/worktree-recovery.js +100 -0
- package/src/memory/auto-linker.js +152 -0
- package/src/memory/benchmark.js +498 -0
- package/src/memory/dedup.js +126 -0
- package/src/memory/embedding-cache.js +136 -0
- package/src/memory/fact-extractor.js +168 -0
- package/src/memory/fts5.js +65 -1
- package/src/memory/migrations/004-bitemporal.js +91 -0
- package/src/memory/migrations/005-vector-cache.js +61 -0
- package/src/memory/migrations/006-obsidian-graph.js +46 -0
- package/src/memory/migrations/007-skill-telemetry.js +24 -0
- package/src/memory/migrations/008-write-provenance.js +41 -0
- package/src/memory/obsidian-parser.js +91 -0
- package/src/memory/query-dataview.js +86 -0
- package/src/memory/search.js +10 -0
- package/src/memory/temporal.js +529 -0
- package/src/memory/tokenize.js +10 -0
- package/src/memory-facts-handler.js +37 -0
- package/src/memory-feedback.js +260 -2
- package/src/model-refresh.js +292 -0
- package/src/observability/cost-anomaly.js +166 -0
- package/src/observability/evaluator-checkpoint-contract.js +117 -0
- package/src/observability/trace-id.js +163 -0
- package/src/orchestrator/agents-md-blackboard.js +152 -0
- package/src/orchestrator/checkpoint-contract.md +140 -0
- package/src/orchestrator/debug-trident.js +570 -0
- package/src/orchestrator/merge-block-aware.js +350 -0
- package/src/orchestrator/plan-checker.js +475 -0
- package/src/orchestrator/post-done-runner.js +249 -0
- package/src/orchestrator/review.js +136 -0
- package/src/orchestrator/runtime-loop.js +430 -0
- package/src/orchestrator/skill-telemetry-sink.js +29 -0
- package/src/orchestrator/skill-telemetry.js +37 -0
- package/src/orchestrator/state-events.js +459 -0
- package/src/orchestrator/state-sdk.js +1764 -0
- package/src/orchestrator/status-protocol.js +235 -0
- package/src/orchestrator/subagent-telemetry.js +452 -0
- package/src/orchestrator/termination.js +160 -0
- package/src/orchestrator/verification-gate.js +281 -0
- package/src/orchestrator/wave-state.js +564 -0
- package/src/orchestrator/worktree-provision.js +77 -0
- package/src/override-use-registry.js +111 -5
- package/src/receipts.js +36 -4
- package/src/recovery/checkpoint.js +56 -3
- package/src/recovery/code-fixer.js +656 -0
- package/src/recovery/truncation.js +317 -0
- package/src/redactor.js +75 -6
- package/src/runtime-mediator.js +15 -0
- package/src/sanitizer.js +10 -0
- package/src/search-hybrid.js +139 -0
- package/src/server.js +603 -59
- package/src/swarm/worktree.js +27 -4
- package/src/swarm-config.js +113 -12
- package/src/team/domain-templates/book.json +51 -0
- package/src/team/domain-templates/business.json +41 -0
- package/src/team/domain-templates/content.json +50 -0
- package/src/team/domain-templates/design.json +44 -0
- package/src/team/domain-templates/research.json +41 -0
- package/src/team/domain-templates/software.json +40 -0
- package/src/team/generator.js +278 -3
- package/src/team/modify.js +203 -0
- package/src/team/schemas.js +48 -0
- package/src/update-apply.js +19 -3
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// v1.5.0 audit-MED-tok-M1 — Repo-map / brief-compaction for subagent dispatch.
|
|
2
|
+
//
|
|
3
|
+
// Problem: subagent briefs that "dump the source" currently consume 5-10x
|
|
4
|
+
// more input tokens than necessary. The orchestrator does not know which
|
|
5
|
+
// files matter to a given task, so it conservatively includes everything.
|
|
6
|
+
//
|
|
7
|
+
// Solution (Aider-style, simplified): walk the repo, score each file with
|
|
8
|
+
// TF-IDF on its symbols + path, and emit a compact map that fits in a fixed
|
|
9
|
+
// token budget (default 1k tokens). The brief gets prepended with this map
|
|
10
|
+
// so a downstream subagent can read it instead of crawling the tree.
|
|
11
|
+
//
|
|
12
|
+
// Design choices:
|
|
13
|
+
// - Zero deps (no tree-sitter, no fs-walk libs). The IJFW project rule is
|
|
14
|
+
// pure-Node. Symbol extraction is a regex pass (good enough for JS/TS/
|
|
15
|
+
// Python/Go signatures + markdown headings). PageRank is approximated
|
|
16
|
+
// with TF-IDF importance ranking — cheap, deterministic, and stable
|
|
17
|
+
// across runs.
|
|
18
|
+
// - Respects .gitignore via a simple parse (no glob library). The parse
|
|
19
|
+
// handles the common patterns: line comments, blank lines, leading "!"
|
|
20
|
+
// negations, trailing "/" directory markers, and "*" wildcards.
|
|
21
|
+
// - Token budget is enforced post-rank: take the top-N files until the
|
|
22
|
+
// running estimate of token cost (chars / 4) exceeds the budget.
|
|
23
|
+
//
|
|
24
|
+
// Public API:
|
|
25
|
+
// buildRepoMap({ rootDir, budgetTokens=1000, maxFiles=200, extensions=[...] })
|
|
26
|
+
// returns { files: [{ path, summary, importance }], totalTokens, truncated }
|
|
27
|
+
//
|
|
28
|
+
// compactBriefForSubagent({ baseBrief, repoMap, maxPrefixTokens })
|
|
29
|
+
// returns { brief: string, repoMapTokens, baseBriefTokens }
|
|
30
|
+
|
|
31
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
32
|
+
import { join, relative, sep, basename, extname } from 'node:path';
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Constants
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
const DEFAULT_BUDGET_TOKENS = 1000;
|
|
39
|
+
const DEFAULT_MAX_FILES = 200;
|
|
40
|
+
const TOKENS_PER_CHAR = 1 / 4; // ~4 chars per token (Anthropic/OpenAI average)
|
|
41
|
+
|
|
42
|
+
// File extensions considered "code" by default. Markdown/JSON ride along
|
|
43
|
+
// because they often carry the most-informative summaries (README, package.json).
|
|
44
|
+
const DEFAULT_EXTENSIONS = new Set([
|
|
45
|
+
'.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
|
|
46
|
+
'.py', '.go', '.rs', '.rb', '.java', '.kt', '.swift',
|
|
47
|
+
'.c', '.cc', '.cpp', '.h', '.hpp',
|
|
48
|
+
'.md', '.json', '.yaml', '.yml', '.toml',
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
// Always-skip directory names (independent of .gitignore). These are
|
|
52
|
+
// universally noise for repo-map purposes.
|
|
53
|
+
const ALWAYS_SKIP_DIRS = new Set([
|
|
54
|
+
'.git', 'node_modules', '.svn', '.hg', '__pycache__',
|
|
55
|
+
'.next', '.nuxt', '.cache', '.vercel', 'dist', 'build', 'coverage',
|
|
56
|
+
'.pytest_cache', '.mypy_cache', '.tox', 'venv', '.venv', 'env',
|
|
57
|
+
// IJFW-specific noise
|
|
58
|
+
'.ijfw', '.planning',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// .gitignore parsing — minimal but covers the cases that matter.
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Parse a .gitignore file into a list of matcher objects.
|
|
67
|
+
* Each matcher is { pattern, negate, dirOnly, anchored, regex }.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} content
|
|
70
|
+
* @returns {Array}
|
|
71
|
+
*/
|
|
72
|
+
export function parseGitignore(content) {
|
|
73
|
+
if (typeof content !== 'string') return [];
|
|
74
|
+
const matchers = [];
|
|
75
|
+
for (const rawLine of content.split('\n')) {
|
|
76
|
+
let line = rawLine.replace(/\r$/, '');
|
|
77
|
+
line = line.replace(/\s+$/, '');
|
|
78
|
+
if (line.length === 0) continue;
|
|
79
|
+
if (line.startsWith('#')) continue;
|
|
80
|
+
let negate = false;
|
|
81
|
+
if (line.startsWith('!')) {
|
|
82
|
+
negate = true;
|
|
83
|
+
line = line.slice(1);
|
|
84
|
+
}
|
|
85
|
+
let anchored = false;
|
|
86
|
+
if (line.startsWith('/')) {
|
|
87
|
+
anchored = true;
|
|
88
|
+
line = line.slice(1);
|
|
89
|
+
}
|
|
90
|
+
let dirOnly = false;
|
|
91
|
+
if (line.endsWith('/')) {
|
|
92
|
+
dirOnly = true;
|
|
93
|
+
line = line.slice(0, -1);
|
|
94
|
+
}
|
|
95
|
+
if (line.length === 0) continue;
|
|
96
|
+
matchers.push({ pattern: line, negate, dirOnly, anchored, regex: globToRegex(line, anchored) });
|
|
97
|
+
}
|
|
98
|
+
return matchers;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Glob -> RegExp. Supports: *, **, ?, character classes.
|
|
102
|
+
function globToRegex(glob, anchored) {
|
|
103
|
+
let re = '';
|
|
104
|
+
for (let i = 0; i < glob.length; i++) {
|
|
105
|
+
const c = glob[i];
|
|
106
|
+
if (c === '*') {
|
|
107
|
+
if (glob[i + 1] === '*') {
|
|
108
|
+
re += '.*';
|
|
109
|
+
i++;
|
|
110
|
+
if (glob[i + 1] === '/') i++;
|
|
111
|
+
} else {
|
|
112
|
+
re += '[^/]*';
|
|
113
|
+
}
|
|
114
|
+
} else if (c === '?') {
|
|
115
|
+
re += '[^/]';
|
|
116
|
+
} else if (c === '.' || c === '+' || c === '(' || c === ')' || c === '|' || c === '^' || c === '$' || c === '\\') {
|
|
117
|
+
re += '\\' + c;
|
|
118
|
+
} else if (c === '/') {
|
|
119
|
+
re += '/';
|
|
120
|
+
} else {
|
|
121
|
+
re += c;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const prefix = anchored ? '^' : '(^|.*/)';
|
|
125
|
+
const suffix = '(/.*)?$';
|
|
126
|
+
return new RegExp(prefix + re + suffix);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Test a relative posix-style path against parsed .gitignore matchers.
|
|
131
|
+
* Returns true if the path should be IGNORED.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} relPathPosix
|
|
134
|
+
* @param {boolean} isDir
|
|
135
|
+
* @param {Array} matchers
|
|
136
|
+
* @returns {boolean}
|
|
137
|
+
*/
|
|
138
|
+
export function isIgnored(relPathPosix, isDir, matchers) {
|
|
139
|
+
let ignored = false;
|
|
140
|
+
for (const m of matchers) {
|
|
141
|
+
if (m.dirOnly && !isDir) continue;
|
|
142
|
+
if (m.regex.test(relPathPosix)) {
|
|
143
|
+
ignored = !m.negate;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return ignored;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Filesystem walk
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
function* walkFiles(rootDir, matchers, extensions) {
|
|
154
|
+
const stack = [rootDir];
|
|
155
|
+
while (stack.length > 0) {
|
|
156
|
+
const dir = stack.pop();
|
|
157
|
+
let entries;
|
|
158
|
+
try {
|
|
159
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
160
|
+
} catch {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
for (const ent of entries) {
|
|
164
|
+
const abs = join(dir, ent.name);
|
|
165
|
+
const rel = relative(rootDir, abs).split(sep).join('/');
|
|
166
|
+
if (ent.isDirectory()) {
|
|
167
|
+
if (ALWAYS_SKIP_DIRS.has(ent.name)) continue;
|
|
168
|
+
if (isIgnored(rel, true, matchers)) continue;
|
|
169
|
+
stack.push(abs);
|
|
170
|
+
} else if (ent.isFile()) {
|
|
171
|
+
if (isIgnored(rel, false, matchers)) continue;
|
|
172
|
+
if (extensions && !extensions.has(extname(ent.name).toLowerCase())) continue;
|
|
173
|
+
yield { abs, rel };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Symbol extraction + TF-IDF
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
// Heuristic regex-based symbol extractor. Catches:
|
|
184
|
+
// - JS/TS: function foo, const foo =, class Foo, export ...
|
|
185
|
+
// - Python: def foo, class Foo
|
|
186
|
+
// - Go: func Foo
|
|
187
|
+
// - Markdown: ^# heading text
|
|
188
|
+
// The goal is "stable enough to rank importance", not perfect AST parsing.
|
|
189
|
+
/* eslint-disable security/detect-unsafe-regex --
|
|
190
|
+
* SYMBOL_PATTERNS scans developer-authored source code files on the local
|
|
191
|
+
* filesystem (not untrusted network input). Each alternation segment is
|
|
192
|
+
* bounded by the source file's line length and the identifier character
|
|
193
|
+
* class is bounded by line content. ReDoS attack surface is not present
|
|
194
|
+
* because the input is repo content the developer chose to add.
|
|
195
|
+
*/
|
|
196
|
+
const SYMBOL_PATTERNS = [
|
|
197
|
+
/(?:^|\n)\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g,
|
|
198
|
+
/(?:^|\n)\s*(?:export\s+)?class\s+([A-Za-z_$][\w$]*)/g,
|
|
199
|
+
/(?:^|\n)\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g,
|
|
200
|
+
/(?:^|\n)\s*def\s+([A-Za-z_][\w]*)/g,
|
|
201
|
+
/(?:^|\n)\s*func\s+(?:\([^)]*\)\s+)?([A-Z][\w]*)/g,
|
|
202
|
+
/(?:^|\n)#{1,3}\s+(.{1,80})/g,
|
|
203
|
+
];
|
|
204
|
+
/* eslint-enable security/detect-unsafe-regex */
|
|
205
|
+
|
|
206
|
+
function extractSymbols(content) {
|
|
207
|
+
const symbols = [];
|
|
208
|
+
for (const pat of SYMBOL_PATTERNS) {
|
|
209
|
+
let m;
|
|
210
|
+
pat.lastIndex = 0;
|
|
211
|
+
while ((m = pat.exec(content)) !== null) {
|
|
212
|
+
const s = m[1];
|
|
213
|
+
if (s && s.length > 0) symbols.push(s.trim());
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return symbols;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Build a TF-IDF importance score per file. "Document" = file's symbols +
|
|
220
|
+
// path components. "Corpus" = all scanned files. Files with more distinctive
|
|
221
|
+
// symbols (rare across the corpus) score higher.
|
|
222
|
+
function tfidfScore(perFileTokens) {
|
|
223
|
+
const docFreq = new Map();
|
|
224
|
+
for (const tokens of perFileTokens.values()) {
|
|
225
|
+
const uniq = new Set(tokens);
|
|
226
|
+
for (const t of uniq) docFreq.set(t, (docFreq.get(t) || 0) + 1);
|
|
227
|
+
}
|
|
228
|
+
const N = perFileTokens.size;
|
|
229
|
+
const scores = new Map();
|
|
230
|
+
for (const [path, tokens] of perFileTokens.entries()) {
|
|
231
|
+
if (tokens.length === 0) { scores.set(path, 0); continue; }
|
|
232
|
+
const tf = new Map();
|
|
233
|
+
for (const t of tokens) tf.set(t, (tf.get(t) || 0) + 1);
|
|
234
|
+
let score = 0;
|
|
235
|
+
for (const [t, count] of tf.entries()) {
|
|
236
|
+
const df = docFreq.get(t) || 1;
|
|
237
|
+
const idf = Math.log((N + 1) / df) + 1;
|
|
238
|
+
score += (count / tokens.length) * idf;
|
|
239
|
+
}
|
|
240
|
+
scores.set(path, score);
|
|
241
|
+
}
|
|
242
|
+
return scores;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// Public API: buildRepoMap
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Build a compact, importance-ranked map of the repo.
|
|
251
|
+
*
|
|
252
|
+
* @param {object} args
|
|
253
|
+
* @param {string} args.rootDir
|
|
254
|
+
* @param {number} [args.budgetTokens=1000]
|
|
255
|
+
* @param {number} [args.maxFiles=200]
|
|
256
|
+
* @param {Set<string>|string[]} [args.extensions]
|
|
257
|
+
* @returns {{ files: Array<{path:string, summary:string, importance:number}>, totalTokens:number, truncated:boolean, scannedCount:number }}
|
|
258
|
+
*/
|
|
259
|
+
export function buildRepoMap({ rootDir, budgetTokens = DEFAULT_BUDGET_TOKENS, maxFiles = DEFAULT_MAX_FILES, extensions } = {}) {
|
|
260
|
+
if (typeof rootDir !== 'string' || rootDir.length === 0) {
|
|
261
|
+
throw new TypeError('buildRepoMap: rootDir is required');
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
const st = statSync(rootDir);
|
|
265
|
+
if (!st.isDirectory()) throw new Error('rootDir must be a directory');
|
|
266
|
+
} catch (err) {
|
|
267
|
+
throw new Error(`buildRepoMap: cannot access rootDir "${rootDir}": ${err.message}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const extSet = extensions instanceof Set
|
|
271
|
+
? extensions
|
|
272
|
+
: Array.isArray(extensions)
|
|
273
|
+
? new Set(extensions.map(e => e.toLowerCase()))
|
|
274
|
+
: DEFAULT_EXTENSIONS;
|
|
275
|
+
|
|
276
|
+
let matchers = [];
|
|
277
|
+
try {
|
|
278
|
+
const giContent = readFileSync(join(rootDir, '.gitignore'), 'utf8');
|
|
279
|
+
matchers = parseGitignore(giContent);
|
|
280
|
+
} catch {
|
|
281
|
+
matchers = [];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const perFileTokens = new Map();
|
|
285
|
+
const perFileSummary = new Map();
|
|
286
|
+
let scannedCount = 0;
|
|
287
|
+
for (const { abs, rel } of walkFiles(rootDir, matchers, extSet)) {
|
|
288
|
+
scannedCount++;
|
|
289
|
+
if (scannedCount > 10_000) break;
|
|
290
|
+
let content = '';
|
|
291
|
+
try {
|
|
292
|
+
content = readFileSync(abs, 'utf8');
|
|
293
|
+
} catch {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (content.length > 256 * 1024) content = content.slice(0, 256 * 1024);
|
|
297
|
+
|
|
298
|
+
const symbols = extractSymbols(content);
|
|
299
|
+
const pathTokens = rel.split(/[/.]/).filter(t => t.length > 1);
|
|
300
|
+
perFileTokens.set(rel, [...symbols, ...pathTokens]);
|
|
301
|
+
|
|
302
|
+
const distinct = [...new Set(symbols)].slice(0, 3);
|
|
303
|
+
const firstLine = (content.split('\n').find(l => l.trim().length > 0) || '').trim().slice(0, 80);
|
|
304
|
+
const summary = distinct.length > 0
|
|
305
|
+
? `${distinct.join(', ')}${firstLine ? ` - ${firstLine}` : ''}`
|
|
306
|
+
: firstLine || basename(rel);
|
|
307
|
+
perFileSummary.set(rel, summary);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const scores = tfidfScore(perFileTokens);
|
|
311
|
+
const ranked = [...perFileTokens.keys()]
|
|
312
|
+
.map(p => ({ path: p, summary: perFileSummary.get(p) || basename(p), importance: scores.get(p) || 0 }))
|
|
313
|
+
.sort((a, b) => b.importance - a.importance);
|
|
314
|
+
|
|
315
|
+
const limited = ranked.slice(0, maxFiles);
|
|
316
|
+
|
|
317
|
+
const out = [];
|
|
318
|
+
let running = 0;
|
|
319
|
+
let truncated = false;
|
|
320
|
+
for (const entry of limited) {
|
|
321
|
+
const line = `${entry.path}: ${entry.summary}\n`;
|
|
322
|
+
const cost = Math.ceil(line.length * TOKENS_PER_CHAR);
|
|
323
|
+
if (running + cost > budgetTokens) {
|
|
324
|
+
truncated = true;
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
out.push(entry);
|
|
328
|
+
running += cost;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return { files: out, totalTokens: running, truncated, scannedCount };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// Public API: compactBriefForSubagent
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Inject a repo-map prefix block in front of a subagent brief.
|
|
340
|
+
*
|
|
341
|
+
* @param {object} args
|
|
342
|
+
* @param {string} args.baseBrief
|
|
343
|
+
* @param {object} args.repoMap - output of buildRepoMap()
|
|
344
|
+
* @param {number} [args.maxPrefixTokens=1000]
|
|
345
|
+
* @returns {{ brief: string, repoMapTokens: number, baseBriefTokens: number }}
|
|
346
|
+
*/
|
|
347
|
+
export function compactBriefForSubagent({ baseBrief, repoMap, maxPrefixTokens = DEFAULT_BUDGET_TOKENS } = {}) {
|
|
348
|
+
if (typeof baseBrief !== 'string') {
|
|
349
|
+
throw new TypeError('compactBriefForSubagent: baseBrief must be a string');
|
|
350
|
+
}
|
|
351
|
+
if (!repoMap || !Array.isArray(repoMap.files)) {
|
|
352
|
+
return {
|
|
353
|
+
brief: baseBrief,
|
|
354
|
+
repoMapTokens: 0,
|
|
355
|
+
baseBriefTokens: Math.ceil(baseBrief.length * TOKENS_PER_CHAR),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const header = '--- REPO MAP (importance-ranked, regex-extracted) ---\n';
|
|
360
|
+
const footer = '--- END REPO MAP ---\n\n';
|
|
361
|
+
const fixedCost = Math.ceil((header.length + footer.length) * TOKENS_PER_CHAR);
|
|
362
|
+
let budget = Math.max(0, maxPrefixTokens - fixedCost);
|
|
363
|
+
|
|
364
|
+
const lines = [];
|
|
365
|
+
let running = 0;
|
|
366
|
+
for (const f of repoMap.files) {
|
|
367
|
+
const line = `${f.path}: ${f.summary}\n`;
|
|
368
|
+
const cost = Math.ceil(line.length * TOKENS_PER_CHAR);
|
|
369
|
+
if (running + cost > budget) break;
|
|
370
|
+
lines.push(line);
|
|
371
|
+
running += cost;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const prefix = lines.length > 0
|
|
375
|
+
? header + lines.join('') + footer
|
|
376
|
+
: '';
|
|
377
|
+
|
|
378
|
+
const brief = prefix + baseBrief;
|
|
379
|
+
return {
|
|
380
|
+
brief,
|
|
381
|
+
repoMapTokens: prefix.length > 0 ? Math.ceil(prefix.length * TOKENS_PER_CHAR) : 0,
|
|
382
|
+
baseBriefTokens: Math.ceil(baseBrief.length * TOKENS_PER_CHAR),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Constants exported for tests + docs.
|
|
387
|
+
export const REPO_MAP_DEFAULTS = {
|
|
388
|
+
budgetTokens: DEFAULT_BUDGET_TOKENS,
|
|
389
|
+
maxFiles: DEFAULT_MAX_FILES,
|
|
390
|
+
tokensPerChar: TOKENS_PER_CHAR,
|
|
391
|
+
extensions: DEFAULT_EXTENSIONS,
|
|
392
|
+
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// shasum-verify.js -- cross-verify a target npm release against the GitLab
|
|
2
|
+
// release asset shasum. Second-factor integrity check on top of
|
|
3
|
+
// `npm audit signatures` (F-SEC-7, v1.5.0 audit-H2.2).
|
|
4
|
+
//
|
|
5
|
+
// THREAT MODEL
|
|
6
|
+
// `npm audit signatures` proves the tarball was signed by the package's
|
|
7
|
+
// npm registry signing keys. This module independently fetches the
|
|
8
|
+
// shasum the publisher recorded on the GitLab release page and compares
|
|
9
|
+
// it against the npm-reported tarball shasum. A divergence means either
|
|
10
|
+
// the npm registry is serving a tampered tarball, OR the GitLab release
|
|
11
|
+
// was tampered with, OR the publisher made an inconsistent release.
|
|
12
|
+
// In all cases: refuse to install.
|
|
13
|
+
//
|
|
14
|
+
// MODES
|
|
15
|
+
// verified -- npm and GitLab shasums both available and match.
|
|
16
|
+
// mismatch -- both available but DIFFER. Fail closed.
|
|
17
|
+
// advisory -- GitLab side missing (older release, no shasum published,
|
|
18
|
+
// or transient fetch failure). Caller decides whether to
|
|
19
|
+
// proceed: interactive prompts for confirmation, non-
|
|
20
|
+
// interactive must abort.
|
|
21
|
+
// error -- npm side missing (no shasum reported by `npm view`).
|
|
22
|
+
// This indicates a deeper problem; refuse to install.
|
|
23
|
+
//
|
|
24
|
+
// CALLER CONTRACT
|
|
25
|
+
// verifyShasumCrossSource(version, opts, deps) returns
|
|
26
|
+
// { ok: boolean, mode, npmShasum, releaseShasum, message }
|
|
27
|
+
// The orchestrator MUST refuse to install when ok === false.
|
|
28
|
+
// advisory mode returns ok: true with a `requiresConfirmation: true`
|
|
29
|
+
// flag so the orchestrator can prompt or fail-closed in non-interactive.
|
|
30
|
+
|
|
31
|
+
import { spawnSync } from 'node:child_process';
|
|
32
|
+
|
|
33
|
+
// Default GitLab project path. Kept here so tests can override.
|
|
34
|
+
export const DEFAULT_GITLAB_PROJECT = 'therealseandonahoe%2Fijfw';
|
|
35
|
+
|
|
36
|
+
// Hex shasum extractor. Accepts standalone hex lines (sha1=40 hex, sha256=64
|
|
37
|
+
// hex) or the common labelled forms used in release notes:
|
|
38
|
+
// shasum: <hex>
|
|
39
|
+
// sha1: <hex>
|
|
40
|
+
// sha256: <hex>
|
|
41
|
+
// sha512: <hex>
|
|
42
|
+
// Returns the first 40-or-more hex run that looks like a shasum.
|
|
43
|
+
// Case-insensitive comparison happens at compare time.
|
|
44
|
+
const SHASUM_LABEL_RE = /(?:shasum|sha-?(?:1|256|512))\s*[:=]\s*([a-f0-9]{40,128})/i;
|
|
45
|
+
const SHASUM_BARE_RE = /\b([a-f0-9]{40})\b/i; // sha1 length is what npm publishes
|
|
46
|
+
const SHASUM256_BARE_RE = /\b([a-f0-9]{64})\b/i;
|
|
47
|
+
|
|
48
|
+
export function extractShasumFromText(text) {
|
|
49
|
+
if (!text || typeof text !== 'string') return null;
|
|
50
|
+
const labelMatch = text.match(SHASUM_LABEL_RE);
|
|
51
|
+
if (labelMatch) return labelMatch[1].toLowerCase();
|
|
52
|
+
// Fall back to bare hex runs only if a label wasn't found. Prefer sha256
|
|
53
|
+
// first because sha1 has higher false-positive risk in narrative text.
|
|
54
|
+
const m256 = text.match(SHASUM256_BARE_RE);
|
|
55
|
+
if (m256) return m256[1].toLowerCase();
|
|
56
|
+
const m1 = text.match(SHASUM_BARE_RE);
|
|
57
|
+
if (m1) return m1[1].toLowerCase();
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Default deps: real npm + real curl. Tests inject pure-JS mocks.
|
|
62
|
+
const DEFAULT_DEPS = Object.freeze({
|
|
63
|
+
// (pkg, version) -> { ok, shasum, message }
|
|
64
|
+
fetchNpmShasum(pkg, version) {
|
|
65
|
+
const ref = `${pkg}@${version}`;
|
|
66
|
+
const r = spawnSync('npm', ['view', ref, 'dist.shasum', '--json'], {
|
|
67
|
+
encoding: 'utf8',
|
|
68
|
+
timeout: 15_000,
|
|
69
|
+
shell: process.platform === 'win32',
|
|
70
|
+
});
|
|
71
|
+
if (r.error) return { ok: false, message: `spawn-${r.error.code || 'unknown'}` };
|
|
72
|
+
if (r.signal) return { ok: false, message: `killed by ${r.signal}` };
|
|
73
|
+
if (r.status !== 0) {
|
|
74
|
+
const stderr = (r.stderr || '').trim();
|
|
75
|
+
return { ok: false, message: stderr || `npm view exited ${r.status}` };
|
|
76
|
+
}
|
|
77
|
+
// npm view ... --json returns the string wrapped in quotes
|
|
78
|
+
const raw = (r.stdout || '').trim().replace(/^"|"$/g, '');
|
|
79
|
+
if (!/^[a-f0-9]{40}$/i.test(raw)) {
|
|
80
|
+
return { ok: false, message: `npm returned non-shasum: ${raw.slice(0, 80)}` };
|
|
81
|
+
}
|
|
82
|
+
return { ok: true, shasum: raw.toLowerCase() };
|
|
83
|
+
},
|
|
84
|
+
// (project, version) -> { ok, body, message }
|
|
85
|
+
fetchGitlabReleaseBody(project, version) {
|
|
86
|
+
const url = `https://gitlab.com/api/v4/projects/${project}/releases/v${version}`;
|
|
87
|
+
const r = spawnSync('curl', ['-fsSL', '-H', 'User-Agent: ijfw', url], {
|
|
88
|
+
encoding: 'utf8',
|
|
89
|
+
timeout: 10_000,
|
|
90
|
+
});
|
|
91
|
+
if (r.error) return { ok: false, message: `spawn-${r.error.code || 'unknown'}` };
|
|
92
|
+
if (r.signal) return { ok: false, message: `killed by ${r.signal}` };
|
|
93
|
+
if (r.status !== 0) {
|
|
94
|
+
return { ok: false, message: `curl exited ${r.status}` };
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const data = JSON.parse(r.stdout || '{}');
|
|
98
|
+
return { ok: true, body: data.description || '' };
|
|
99
|
+
} catch {
|
|
100
|
+
return { ok: false, message: 'release JSON parse failed' };
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// version: semver string (validated by caller)
|
|
106
|
+
// opts: { pkg = '@ijfw/install', project = DEFAULT_GITLAB_PROJECT }
|
|
107
|
+
// deps: optional mock injection
|
|
108
|
+
export function verifyShasumCrossSource(version, opts = {}, deps = DEFAULT_DEPS) {
|
|
109
|
+
const pkg = opts.pkg || '@ijfw/install';
|
|
110
|
+
const project = opts.project || DEFAULT_GITLAB_PROJECT;
|
|
111
|
+
const fetchNpm = deps.fetchNpmShasum || DEFAULT_DEPS.fetchNpmShasum;
|
|
112
|
+
const fetchRelease = deps.fetchGitlabReleaseBody || DEFAULT_DEPS.fetchGitlabReleaseBody;
|
|
113
|
+
|
|
114
|
+
const npmRes = fetchNpm(pkg, version);
|
|
115
|
+
if (!npmRes.ok) {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
mode: 'error',
|
|
119
|
+
npmShasum: null,
|
|
120
|
+
releaseShasum: null,
|
|
121
|
+
message: `npm shasum lookup failed: ${npmRes.message}`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const npmShasum = String(npmRes.shasum || '').toLowerCase();
|
|
125
|
+
|
|
126
|
+
const releaseRes = fetchRelease(project, version);
|
|
127
|
+
if (!releaseRes.ok) {
|
|
128
|
+
return {
|
|
129
|
+
ok: true,
|
|
130
|
+
mode: 'advisory',
|
|
131
|
+
requiresConfirmation: true,
|
|
132
|
+
npmShasum,
|
|
133
|
+
releaseShasum: null,
|
|
134
|
+
message: `release shasum unavailable (${releaseRes.message}); proceed only with explicit confirmation`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const releaseShasum = extractShasumFromText(releaseRes.body);
|
|
138
|
+
if (!releaseShasum) {
|
|
139
|
+
return {
|
|
140
|
+
ok: true,
|
|
141
|
+
mode: 'advisory',
|
|
142
|
+
requiresConfirmation: true,
|
|
143
|
+
npmShasum,
|
|
144
|
+
releaseShasum: null,
|
|
145
|
+
message: 'GitLab release does not list a shasum for this version; proceed only with explicit confirmation',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (releaseShasum.toLowerCase() !== npmShasum) {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
mode: 'mismatch',
|
|
152
|
+
npmShasum,
|
|
153
|
+
releaseShasum: releaseShasum.toLowerCase(),
|
|
154
|
+
message: `shasum mismatch: npm=${npmShasum} release=${releaseShasum.toLowerCase()}`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
ok: true,
|
|
159
|
+
mode: 'verified',
|
|
160
|
+
npmShasum,
|
|
161
|
+
releaseShasum: releaseShasum.toLowerCase(),
|
|
162
|
+
message: 'shasum cross-verified (npm dist.shasum matches GitLab release asset)',
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// sketches-gc.js -- v1.5.0 audit-MED-design-#9.
|
|
2
|
+
//
|
|
3
|
+
// Auto-archive sketches older than N days from .planning/sketches/ to
|
|
4
|
+
// .planning/sketches/.archive/. Idempotent. Zero deps. Pure stdlib.
|
|
5
|
+
//
|
|
6
|
+
// Design notes:
|
|
7
|
+
// - "Sketches" are throwaway HTML mockups under .planning/sketches/<name>/.
|
|
8
|
+
// Without GC they accumulate indefinitely -- the audit MED.
|
|
9
|
+
// - Default age: 30 days (configurable via opts.maxAgeMs).
|
|
10
|
+
// - Archive layout: .planning/sketches/.archive/<original-name>/. If a name
|
|
11
|
+
// collision happens (re-archived twice), suffix with timestamp.
|
|
12
|
+
// - mtime is used (not ctime) -- editing a sketch keeps it fresh.
|
|
13
|
+
// - Walks ONE level deep. Each top-level entry under sketches/ is either a
|
|
14
|
+
// directory (a sketch) or a stray file (also archived).
|
|
15
|
+
// - Returns {archived: [{from, to}], skipped: [{path, reason}], scannedAt}.
|
|
16
|
+
//
|
|
17
|
+
// Used by:
|
|
18
|
+
// - `ijfw run sketches-gc [--root <dir>] [--max-age-days <n>] [--dry-run]`
|
|
19
|
+
// via cross-orchestrator-cli.js
|
|
20
|
+
// - Any cron / hook that wants a maintenance pass.
|
|
21
|
+
|
|
22
|
+
import { existsSync, mkdirSync, readdirSync, renameSync, statSync } from 'node:fs';
|
|
23
|
+
import { join, basename } from 'node:path';
|
|
24
|
+
|
|
25
|
+
const DEFAULT_MAX_AGE_DAYS = 30;
|
|
26
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run a GC pass over a sketches directory.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} opts
|
|
32
|
+
* @param {string} [opts.root] Sketches root. Default: .planning/sketches relative to cwd.
|
|
33
|
+
* @param {number} [opts.maxAgeMs] Max age in ms. Default: 30 days.
|
|
34
|
+
* @param {number} [opts.maxAgeDays] Convenience -- overrides maxAgeMs when set.
|
|
35
|
+
* @param {boolean} [opts.dryRun] When true, return the plan but do not move anything.
|
|
36
|
+
* @param {Date} [opts.now] Reference "now" -- injected for tests.
|
|
37
|
+
* @returns {{archived: Array<{from:string,to:string,ageDays:number}>, skipped: Array<{path:string,reason:string}>, scannedAt:string, archiveDir:string, root:string}}
|
|
38
|
+
*/
|
|
39
|
+
export function runSketchesGc(opts = {}) {
|
|
40
|
+
const root = opts.root || join(process.cwd(), '.planning', 'sketches');
|
|
41
|
+
const now = opts.now instanceof Date ? opts.now : new Date();
|
|
42
|
+
const maxAgeMs =
|
|
43
|
+
typeof opts.maxAgeDays === 'number'
|
|
44
|
+
? opts.maxAgeDays * MS_PER_DAY
|
|
45
|
+
: typeof opts.maxAgeMs === 'number'
|
|
46
|
+
? opts.maxAgeMs
|
|
47
|
+
: DEFAULT_MAX_AGE_DAYS * MS_PER_DAY;
|
|
48
|
+
const dryRun = opts.dryRun === true;
|
|
49
|
+
|
|
50
|
+
const archived = [];
|
|
51
|
+
const skipped = [];
|
|
52
|
+
const archiveDir = join(root, '.archive');
|
|
53
|
+
const scannedAt = now.toISOString();
|
|
54
|
+
|
|
55
|
+
if (!existsSync(root)) {
|
|
56
|
+
return { archived, skipped, scannedAt, archiveDir, root };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Ensure archive dir exists (unless dry-run, then we just record intent).
|
|
60
|
+
if (!dryRun && !existsSync(archiveDir)) {
|
|
61
|
+
mkdirSync(archiveDir, { recursive: true, mode: 0o755 });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let entries;
|
|
65
|
+
try {
|
|
66
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return { archived, skipped: [{ path: root, reason: `readdir-failed: ${e.code || e.message}` }], scannedAt, archiveDir, root };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
const name = entry.name;
|
|
73
|
+
// Skip the archive dir itself + dotfiles (hidden state, READMEs, etc.).
|
|
74
|
+
if (name === '.archive') continue;
|
|
75
|
+
if (name.startsWith('.')) {
|
|
76
|
+
skipped.push({ path: join(root, name), reason: 'dotfile' });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const src = join(root, name);
|
|
80
|
+
let st;
|
|
81
|
+
try {
|
|
82
|
+
st = statSync(src);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
skipped.push({ path: src, reason: `stat-failed: ${e.code || e.message}` });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const ageMs = now.getTime() - st.mtimeMs;
|
|
88
|
+
if (ageMs < maxAgeMs) {
|
|
89
|
+
skipped.push({ path: src, reason: `fresh (age ${Math.round(ageMs / MS_PER_DAY)}d)` });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Resolve destination. Collision -> suffix with timestamp.
|
|
94
|
+
let dest = join(archiveDir, name);
|
|
95
|
+
if (existsSync(dest)) {
|
|
96
|
+
const stamp = now.toISOString().replace(/[:.]/g, '-');
|
|
97
|
+
dest = join(archiveDir, `${name}.${stamp}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (dryRun) {
|
|
101
|
+
archived.push({ from: src, to: dest, ageDays: Math.round(ageMs / MS_PER_DAY) });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
renameSync(src, dest);
|
|
107
|
+
archived.push({ from: src, to: dest, ageDays: Math.round(ageMs / MS_PER_DAY) });
|
|
108
|
+
} catch (e) {
|
|
109
|
+
skipped.push({ path: src, reason: `rename-failed: ${e.code || e.message}` });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { archived, skipped, scannedAt, archiveDir, root };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Human-readable summary of a GC result -- one line per category.
|
|
118
|
+
* @param {ReturnType<typeof runSketchesGc>} result
|
|
119
|
+
*/
|
|
120
|
+
export function formatGcResult(result) {
|
|
121
|
+
const lines = [];
|
|
122
|
+
lines.push(`sketches-gc -- scanned ${result.root} at ${result.scannedAt}`);
|
|
123
|
+
lines.push(` archived: ${result.archived.length}`);
|
|
124
|
+
for (const a of result.archived) {
|
|
125
|
+
lines.push(` ${basename(a.from)} (${a.ageDays}d) -> ${a.to}`);
|
|
126
|
+
}
|
|
127
|
+
lines.push(` skipped: ${result.skipped.length}`);
|
|
128
|
+
for (const s of result.skipped) {
|
|
129
|
+
lines.push(` ${basename(s.path)} -- ${s.reason}`);
|
|
130
|
+
}
|
|
131
|
+
return lines.join('\n');
|
|
132
|
+
}
|