mirror of
https://github.com/Jeuners/ECC.git
synced 2026-09-09 15:02:30 +02:00
fix: clean promoted instinct sources (#2587)
* fix: clean promoted instinct sources * test(instincts): normalize retained-content line endings --------- Co-authored-by: haelyra <49814733+haelyra@users.noreply.github.com>
This commit is contained in:
parent
4e973d3eaf
commit
6be87a56ae
2 changed files with 256 additions and 0 deletions
|
|
@ -27,6 +27,7 @@ import ipaddress
|
||||||
import socket
|
import socket
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import tempfile
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
@ -1323,6 +1324,106 @@ def _show_promotion_candidates(project: dict) -> None:
|
||||||
print(f" Run `instinct-cli.py promote` to promote these to global scope.\n")
|
print(f" Run `instinct-cli.py promote` to promote these to global scope.\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _frontmatter_scalar(lines: list[str], key: str) -> Optional[str]:
|
||||||
|
"""Extract a simple scalar value from frontmatter lines."""
|
||||||
|
for line in lines:
|
||||||
|
if ':' not in line:
|
||||||
|
continue
|
||||||
|
parsed_key, value = line.split(':', 1)
|
||||||
|
if parsed_key.strip() != key:
|
||||||
|
continue
|
||||||
|
value = value.strip()
|
||||||
|
if value.startswith('"') and value.endswith('"'):
|
||||||
|
return value[1:-1].replace('\\"', '"').replace('\\\\', '\\')
|
||||||
|
if value.startswith("'") and value.endswith("'"):
|
||||||
|
return value[1:-1].replace("''", "'")
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_instinct_blocks(content: str, instinct_id: str) -> tuple[str, int]:
|
||||||
|
"""Remove raw frontmatter blocks with a matching instinct ID."""
|
||||||
|
lines = content.splitlines(keepends=True)
|
||||||
|
retained = []
|
||||||
|
removed = 0
|
||||||
|
index = 0
|
||||||
|
|
||||||
|
while index < len(lines):
|
||||||
|
if lines[index].strip() != '---':
|
||||||
|
retained.append(lines[index])
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
block_start = index
|
||||||
|
frontmatter_end = index + 1
|
||||||
|
while frontmatter_end < len(lines) and lines[frontmatter_end].strip() != '---':
|
||||||
|
frontmatter_end += 1
|
||||||
|
|
||||||
|
if frontmatter_end >= len(lines):
|
||||||
|
retained.extend(lines[block_start:])
|
||||||
|
break
|
||||||
|
|
||||||
|
next_block_start = frontmatter_end + 1
|
||||||
|
while next_block_start < len(lines) and lines[next_block_start].strip() != '---':
|
||||||
|
next_block_start += 1
|
||||||
|
|
||||||
|
block_id = _frontmatter_scalar(lines[block_start + 1:frontmatter_end], 'id')
|
||||||
|
if block_id == instinct_id:
|
||||||
|
removed += 1
|
||||||
|
else:
|
||||||
|
retained.extend(lines[block_start:next_block_start])
|
||||||
|
index = next_block_start
|
||||||
|
|
||||||
|
return ''.join(retained), removed
|
||||||
|
|
||||||
|
|
||||||
|
def _write_text_atomic(file_path: Path, content: str) -> None:
|
||||||
|
"""Replace a text file via same-directory temp file."""
|
||||||
|
temp_fd, temp_name = tempfile.mkstemp(
|
||||||
|
prefix=f".{file_path.name}.",
|
||||||
|
suffix=".tmp",
|
||||||
|
dir=file_path.parent,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
temp_file = Path(temp_name)
|
||||||
|
try:
|
||||||
|
with os.fdopen(temp_fd, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
os.replace(temp_file, file_path)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
temp_file.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_instinct_from_source(source_file_str: str, instinct_id: str) -> None:
|
||||||
|
"""Strip promoted instinct blocks from the project-scoped source file."""
|
||||||
|
source_file = Path(source_file_str)
|
||||||
|
if not source_file.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = source_file.read_text(encoding="utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"Warning: Failed to read promoted instinct source {source_file}: {exc}", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
remaining_content, removed = _remove_instinct_blocks(content, instinct_id)
|
||||||
|
if removed == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if remaining_content:
|
||||||
|
_write_text_atomic(source_file, remaining_content)
|
||||||
|
else:
|
||||||
|
source_file.unlink()
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"Warning: Failed to remove promoted instinct from {source_file}: {exc}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
def cmd_promote(args) -> int:
|
def cmd_promote(args) -> int:
|
||||||
"""Promote project-scoped instincts to global scope."""
|
"""Promote project-scoped instincts to global scope."""
|
||||||
project = detect_project()
|
project = detect_project()
|
||||||
|
|
@ -1385,6 +1486,9 @@ def _promote_specific(project: dict, instinct_id: str, force: bool, dry_run: boo
|
||||||
output_content += target.get('content', '') + "\n"
|
output_content += target.get('content', '') + "\n"
|
||||||
|
|
||||||
output_file.write_text(output_content, encoding="utf-8")
|
output_file.write_text(output_content, encoding="utf-8")
|
||||||
|
source_file = target.get('_source_file')
|
||||||
|
if source_file:
|
||||||
|
_remove_instinct_from_source(source_file, instinct_id)
|
||||||
print(f"\nPromoted '{instinct_id}' to global scope.")
|
print(f"\nPromoted '{instinct_id}' to global scope.")
|
||||||
print(f" Saved to: {output_file}")
|
print(f" Saved to: {output_file}")
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -1458,6 +1562,10 @@ def _promote_auto(project: dict, force: bool, dry_run: bool) -> int:
|
||||||
output_content += inst.get('content', '') + "\n"
|
output_content += inst.get('content', '') + "\n"
|
||||||
|
|
||||||
output_file.write_text(output_content, encoding="utf-8")
|
output_file.write_text(output_content, encoding="utf-8")
|
||||||
|
for _, _, entry_inst in cand['entries']:
|
||||||
|
entry_source = entry_inst.get('_source_file')
|
||||||
|
if entry_source:
|
||||||
|
_remove_instinct_from_source(entry_source, cand['id'])
|
||||||
promoted += 1
|
promoted += 1
|
||||||
|
|
||||||
print(f"\nPromoted {promoted} instincts to global scope.")
|
print(f"\nPromoted {promoted} instincts to global scope.")
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,10 @@ function readJson(filePath) {
|
||||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeLineEndings(value) {
|
||||||
|
return value.replace(/\r\n/g, '\n');
|
||||||
|
}
|
||||||
|
|
||||||
function writeInstinct(filePath, id, confidence = 0.9) {
|
function writeInstinct(filePath, id, confidence = 0.9) {
|
||||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|
@ -117,6 +121,13 @@ function runGit(cwd, args) {
|
||||||
return result.stdout.trim();
|
return result.stdout.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initGitProject(parentDir, name = 'repo') {
|
||||||
|
const repoDir = path.join(parentDir, name);
|
||||||
|
fs.mkdirSync(repoDir, { recursive: true });
|
||||||
|
runGit(repoDir, ['init']);
|
||||||
|
return repoDir;
|
||||||
|
}
|
||||||
|
|
||||||
function runCli(root, args, options = {}) {
|
function runCli(root, args, options = {}) {
|
||||||
return spawnSync(PYTHON3, [cliPath, ...args], {
|
return spawnSync(PYTHON3, [cliPath, ...args], {
|
||||||
cwd: options.cwd || repoRoot,
|
cwd: options.cwd || repoRoot,
|
||||||
|
|
@ -330,6 +341,143 @@ test('status migrates legacy no-remote linked worktree project dirs to main work
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('promote removes only the promoted instinct block from project source', () => {
|
||||||
|
const root = createTempDir();
|
||||||
|
const repoParent = createTempDir();
|
||||||
|
try {
|
||||||
|
const repoDir = initGitProject(repoParent);
|
||||||
|
const projectId = projectHash(runGit(repoDir, ['rev-parse', '--show-toplevel']));
|
||||||
|
const sourceFile = path.join(root, 'projects', projectId, 'instincts', 'personal', 'mixed.yaml');
|
||||||
|
const retainedBlock = [
|
||||||
|
'---',
|
||||||
|
'id: keep-me',
|
||||||
|
'trigger: "when value: contains colon"',
|
||||||
|
'confidence: 0.72',
|
||||||
|
'domain: workflow',
|
||||||
|
'tags: [alpha, beta]',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'Keep this block exactly.',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
fs.mkdirSync(path.dirname(sourceFile), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
sourceFile,
|
||||||
|
[
|
||||||
|
'---',
|
||||||
|
'id: promote-me',
|
||||||
|
'trigger: "when promoting"',
|
||||||
|
'confidence: 0.91',
|
||||||
|
'domain: workflow',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'Promote this block.',
|
||||||
|
'',
|
||||||
|
retainedBlock,
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = runCli(root, ['promote', 'promote-me', '--force'], { cwd: repoDir });
|
||||||
|
assert.strictEqual(result.status, 0, result.stderr);
|
||||||
|
assert.ok(fs.existsSync(path.join(root, 'instincts', 'personal', 'promote-me.yaml')));
|
||||||
|
assert.strictEqual(normalizeLineEndings(fs.readFileSync(sourceFile, 'utf8')), retainedBlock);
|
||||||
|
} finally {
|
||||||
|
cleanupDir(root);
|
||||||
|
cleanupDir(repoParent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('promote deletes project source file when it only contained the promoted instinct', () => {
|
||||||
|
const root = createTempDir();
|
||||||
|
const repoParent = createTempDir();
|
||||||
|
try {
|
||||||
|
const repoDir = initGitProject(repoParent);
|
||||||
|
const projectId = projectHash(runGit(repoDir, ['rev-parse', '--show-toplevel']));
|
||||||
|
const sourceFile = path.join(root, 'projects', projectId, 'instincts', 'personal', 'single.yaml');
|
||||||
|
writeInstinct(sourceFile, 'promote-single', 0.93);
|
||||||
|
|
||||||
|
const result = runCli(root, ['promote', 'promote-single', '--force'], { cwd: repoDir });
|
||||||
|
assert.strictEqual(result.status, 0, result.stderr);
|
||||||
|
assert.ok(fs.existsSync(path.join(root, 'instincts', 'personal', 'promote-single.yaml')));
|
||||||
|
assert.ok(!fs.existsSync(sourceFile));
|
||||||
|
} finally {
|
||||||
|
cleanupDir(root);
|
||||||
|
cleanupDir(repoParent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('promote preserves malformed and foreign source blocks while removing target', () => {
|
||||||
|
const root = createTempDir();
|
||||||
|
const repoParent = createTempDir();
|
||||||
|
try {
|
||||||
|
const repoDir = initGitProject(repoParent);
|
||||||
|
const projectId = projectHash(runGit(repoDir, ['rev-parse', '--show-toplevel']));
|
||||||
|
const sourceFile = path.join(root, 'projects', projectId, 'instincts', 'personal', 'foreign.yaml');
|
||||||
|
const foreignContent = [
|
||||||
|
'---',
|
||||||
|
'title: foreign block without id',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'Do not drop this content.',
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'id: keep-foreign-neighbor',
|
||||||
|
'trigger: "when nearby"',
|
||||||
|
'confidence: not-a-float',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'This parse-tolerated block must also stay raw.',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
fs.mkdirSync(path.dirname(sourceFile), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
sourceFile,
|
||||||
|
[
|
||||||
|
foreignContent,
|
||||||
|
'---',
|
||||||
|
'id: promote-foreign',
|
||||||
|
'trigger: "when target appears"',
|
||||||
|
'confidence: 0.95',
|
||||||
|
'domain: workflow',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'Only this block should be removed.',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = runCli(root, ['promote', 'promote-foreign', '--force'], { cwd: repoDir });
|
||||||
|
assert.strictEqual(result.status, 0, result.stderr);
|
||||||
|
assert.ok(fs.existsSync(path.join(root, 'instincts', 'personal', 'promote-foreign.yaml')));
|
||||||
|
assert.strictEqual(normalizeLineEndings(fs.readFileSync(sourceFile, 'utf8')), `${foreignContent}\n`);
|
||||||
|
} finally {
|
||||||
|
cleanupDir(root);
|
||||||
|
cleanupDir(repoParent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auto-promote removes promoted source copies from every contributing project', () => {
|
||||||
|
const root = createTempDir();
|
||||||
|
try {
|
||||||
|
const registryPath = path.join(root, 'projects.json');
|
||||||
|
const projectOne = seedProject(root, 'proj111', { personal: ['shared-auto'] });
|
||||||
|
const projectTwo = seedProject(root, 'proj222', { personal: ['shared-auto'] });
|
||||||
|
writeJson(registryPath, {
|
||||||
|
proj111: { name: 'one', root: '/repo/one', remote: '', last_seen: '2026-01-01T00:00:00Z' },
|
||||||
|
proj222: { name: 'two', root: '/repo/two', remote: '', last_seen: '2026-01-02T00:00:00Z' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = runCli(root, ['promote', '--force'], { cwd: root });
|
||||||
|
assert.strictEqual(result.status, 0, result.stderr);
|
||||||
|
assert.match(result.stdout, /Promoted 1 instincts to global scope/);
|
||||||
|
assert.ok(fs.existsSync(path.join(root, 'instincts', 'personal', 'shared-auto.yaml')));
|
||||||
|
assert.ok(!fs.existsSync(path.join(projectOne, 'instincts', 'personal', 'shared-auto.yaml')));
|
||||||
|
assert.ok(!fs.existsSync(path.join(projectTwo, 'instincts', 'personal', 'shared-auto.yaml')));
|
||||||
|
} finally {
|
||||||
|
cleanupDir(root);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
console.log(`\nPassed: ${passed}`);
|
console.log(`\nPassed: ${passed}`);
|
||||||
console.log(`Failed: ${failed}`);
|
console.log(`Failed: ${failed}`);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue