runlist 0.0.0 → 0.94.2

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 (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1112 -6
  3. package/assets/app/app.css +283 -0
  4. package/assets/app/app.mjs +381 -0
  5. package/assets/app/block-editor.mjs +182 -0
  6. package/assets/app/block-model.mjs +52 -0
  7. package/assets/app/document-create.mjs +86 -0
  8. package/assets/app/document-lifecycle.mjs +36 -0
  9. package/assets/app/document-stages.mjs +22 -0
  10. package/assets/app/document-views.mjs +33 -0
  11. package/assets/app/document-yardstick.mjs +258 -0
  12. package/assets/app/editor-navigation.mjs +51 -0
  13. package/assets/app/filing-navigation.mjs +138 -0
  14. package/assets/app/git-changes.mjs +80 -0
  15. package/assets/app/git-commit.mjs +46 -0
  16. package/assets/app/index.html +70 -0
  17. package/assets/app/library-navigation.mjs +93 -0
  18. package/assets/app/outline.mjs +18 -0
  19. package/assets/app/quick-navigation.mjs +52 -0
  20. package/assets/app/record-navigation.mjs +78 -0
  21. package/assets/app/recovery-center.mjs +53 -0
  22. package/assets/app/semantic-search.mjs +14 -0
  23. package/assets/app/settings.mjs +15 -0
  24. package/assets/app/shared.mjs +104 -0
  25. package/assets/app/stage-source.mjs +75 -0
  26. package/assets/app/template-editor.mjs +47 -0
  27. package/assets/app/theme.mjs +25 -0
  28. package/assets/app/transport.mjs +43 -0
  29. package/assets/contracts/source-editing-v1.md +232 -0
  30. package/assets/opencode/plugin.js +162 -0
  31. package/assets/schemas/runlist-record-v1.schema.json +912 -0
  32. package/bin/dotmd.mjs +5 -0
  33. package/bin/runlist.mjs +2714 -0
  34. package/package.json +53 -6
  35. package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
  36. package/plugins/runlist-codex/bin/runlist-hook +86 -0
  37. package/plugins/runlist-codex/hooks/hooks.json +16 -0
  38. package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
  39. package/runlist.config.example.mjs +370 -0
  40. package/scripts/postinstall.mjs +56 -0
  41. package/src/agent-context.mjs +201 -0
  42. package/src/ai.mjs +30 -0
  43. package/src/app-create.mjs +169 -0
  44. package/src/app-filing.mjs +139 -0
  45. package/src/app-git-commits.mjs +48 -0
  46. package/src/app-git.mjs +180 -0
  47. package/src/app-library.mjs +152 -0
  48. package/src/app-lifecycle-worker.mjs +12 -0
  49. package/src/app-lifecycle.mjs +79 -0
  50. package/src/app-records.mjs +134 -0
  51. package/src/app-search.mjs +80 -0
  52. package/src/app-semantic-search.mjs +105 -0
  53. package/src/app-service.mjs +208 -0
  54. package/src/app-yardstick.mjs +269 -0
  55. package/src/app.mjs +102 -0
  56. package/src/atomic-mutation.mjs +1736 -0
  57. package/src/baton.mjs +435 -0
  58. package/src/body-link.mjs +48 -0
  59. package/src/bulk-tag.mjs +174 -0
  60. package/src/check-collapse.mjs +75 -0
  61. package/src/claude-commands.mjs +75 -0
  62. package/src/cli-editor.mjs +8 -0
  63. package/src/code-refs.mjs +545 -0
  64. package/src/codex-integration.mjs +81 -0
  65. package/src/color.mjs +14 -0
  66. package/src/commands.mjs +374 -0
  67. package/src/completions.mjs +93 -0
  68. package/src/config-edit.mjs +606 -0
  69. package/src/config.mjs +705 -0
  70. package/src/decision-action.mjs +58 -0
  71. package/src/decision.mjs +220 -0
  72. package/src/decisions.mjs +787 -0
  73. package/src/deps.mjs +317 -0
  74. package/src/desktop-install.mjs +158 -0
  75. package/src/desktop-runtime.mjs +4 -0
  76. package/src/desktop.mjs +25 -0
  77. package/src/diff.mjs +101 -0
  78. package/src/doctor.mjs +591 -0
  79. package/src/durable-rename.mjs +55 -0
  80. package/src/errors-read.mjs +44 -0
  81. package/src/export.mjs +496 -0
  82. package/src/extractors.mjs +101 -0
  83. package/src/filing.mjs +255 -0
  84. package/src/fix-membership.mjs +134 -0
  85. package/src/fix-refs.mjs +174 -0
  86. package/src/flags.mjs +318 -0
  87. package/src/frontmatter-fix.mjs +195 -0
  88. package/src/frontmatter.mjs +252 -0
  89. package/src/git-commit-filters.mjs +32 -0
  90. package/src/git-commit-protocol.mjs +198 -0
  91. package/src/git-commit-signing.mjs +75 -0
  92. package/src/git-commit-store.mjs +26 -0
  93. package/src/git-commit-support.mjs +16 -0
  94. package/src/git-commit-worker.mjs +32 -0
  95. package/src/git.mjs +909 -0
  96. package/src/glossary-check.mjs +35 -0
  97. package/src/glossary.mjs +243 -0
  98. package/src/graph.mjs +298 -0
  99. package/src/guard.mjs +488 -0
  100. package/src/health.mjs +212 -0
  101. package/src/hints.mjs +129 -0
  102. package/src/host-integration.mjs +339 -0
  103. package/src/hub-membership.mjs +284 -0
  104. package/src/hub.mjs +296 -0
  105. package/src/hud.mjs +436 -0
  106. package/src/index-file.mjs +163 -0
  107. package/src/index.mjs +478 -0
  108. package/src/init.mjs +512 -0
  109. package/src/install.mjs +222 -0
  110. package/src/journal-read.mjs +116 -0
  111. package/src/journal.mjs +472 -0
  112. package/src/lifecycle.mjs +1444 -0
  113. package/src/lint.mjs +386 -0
  114. package/src/managed-path.mjs +192 -0
  115. package/src/markdown-code-spans.mjs +58 -0
  116. package/src/migrate-prompts.mjs +171 -0
  117. package/src/migrate-template.mjs +170 -0
  118. package/src/migrate.mjs +90 -0
  119. package/src/misuse-read.mjs +61 -0
  120. package/src/model-request.mjs +33 -0
  121. package/src/model.mjs +523 -0
  122. package/src/modules.mjs +355 -0
  123. package/src/naming.mjs +55 -0
  124. package/src/native-action.mjs +59 -0
  125. package/src/native-record.mjs +244 -0
  126. package/src/new.mjs +1321 -0
  127. package/src/output-identity.mjs +111 -0
  128. package/src/parent-plan.mjs +58 -0
  129. package/src/parse-cache.mjs +99 -0
  130. package/src/path-containment.mjs +15 -0
  131. package/src/pickup-card.mjs +293 -0
  132. package/src/pickup.mjs +671 -0
  133. package/src/prompt.mjs +25 -0
  134. package/src/prompts.mjs +585 -0
  135. package/src/public-inspection.mjs +19 -0
  136. package/src/query.mjs +977 -0
  137. package/src/record-action.mjs +11 -0
  138. package/src/record-create.mjs +82 -0
  139. package/src/reference-planner.mjs +388 -0
  140. package/src/reference-values.mjs +11 -0
  141. package/src/rename.mjs +107 -0
  142. package/src/render.mjs +678 -0
  143. package/src/roadmap.mjs +184 -0
  144. package/src/runlist.mjs +944 -0
  145. package/src/section.mjs +225 -0
  146. package/src/semantic-search-client.mjs +58 -0
  147. package/src/ship.mjs +166 -0
  148. package/src/show.mjs +101 -0
  149. package/src/skill-drift.mjs +98 -0
  150. package/src/source-editor.mjs +491 -0
  151. package/src/stages.mjs +73 -0
  152. package/src/state-migration.mjs +123 -0
  153. package/src/stats.mjs +200 -0
  154. package/src/status-metadata.mjs +87 -0
  155. package/src/statuses.mjs +721 -0
  156. package/src/summary.mjs +74 -0
  157. package/src/surfaces.mjs +28 -0
  158. package/src/sync-status.mjs +316 -0
  159. package/src/template-store.mjs +62 -0
  160. package/src/update.mjs +338 -0
  161. package/src/use.mjs +58 -0
  162. package/src/util.mjs +337 -0
  163. package/src/validate.mjs +795 -0
  164. package/src/watch.mjs +56 -0
  165. package/src/xref.mjs +406 -0
  166. package/src/yardstick-command.mjs +178 -0
  167. package/src/yardstick.mjs +112 -0
@@ -0,0 +1,11 @@
1
+ import {randomUUID} from 'node:crypto';import path from 'node:path';
2
+ import {prepareNativeAction} from './native-action.mjs';import {cliEditor} from './cli-editor.mjs';import {resolveDocArg} from './index.mjs';import {SourceEditError} from './source-editor.mjs';
3
+ export function runRecordAction(argv,config,{dryRun=false,env=process.env}={}){
4
+ const args=[],opts={};for(let i=0;i<argv.length;i++){if(argv[i]==='--json')opts.json=true;else if(argv[i].startsWith('--'))opts[argv[i].slice(2)]=argv[++i];else args.push(argv[i]);}
5
+ const [input,action]=args;if(!input||!action||!opts.note)throw new SourceEditError('native-invalid-action','Use runlist record <file> <action> --note <reason> [--option <option-id>].');
6
+ const file=resolveDocArg(input,config),{editor,actor}=cliEditor(config,file,{env,domainPrepare:prepareNativeAction}),before=editor.read(null,{path:file});
7
+ if(opts['expected-revision']&&opts['expected-revision']!==before.revision)throw new SourceEditError('revision-conflict','The record changed. Read it before recording an action.');
8
+ const request={path:file,operationId:randomUUID(),action,note:opts.note,optionId:opts.option,at:new Date().toISOString(),expectedRevision:before.revision};request.source=prepareNativeAction(before.source,request,actor);
9
+ if(dryRun){process.stdout.write(request.source);return {dryRun:true};}
10
+ const result=editor.nativeAction(null,request);process.stdout.write(opts.json?JSON.stringify(result)+'\n':`${path.relative(config.repoRoot,file)}: ${action} recorded.\n`);return result;
11
+ }
@@ -0,0 +1,82 @@
1
+ import {randomUUID} from 'node:crypto';
2
+ import {existsSync,openSync,closeSync,fstatSync,readSync,constants} from 'node:fs';
3
+ import {execFileSync} from 'node:child_process';
4
+ import path from 'node:path';
5
+ import {catchAllRoot} from './new.mjs';
6
+ import {parseNativeRecord,NATIVE_RECORD_SCHEMA} from './native-record.mjs';
7
+ import {SourceEditError,sourceRevision} from './source-editor.mjs';
8
+
9
+ const fail=message=>{throw new SourceEditError('record-configuration',message);};
10
+ const uuid='[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}';
11
+ const id=kind=>`${kind}:${randomUUID()}`;
12
+ export const recordSettingsPath=config=>path.join(config.repoRoot,'runlist.records.json');
13
+ export function recordSettingsText(config,safe){
14
+ const file=safe(recordSettingsPath(config));if(!existsSync(file))return null;
15
+ let fd;try{
16
+ fd=openSync(file,constants.O_RDONLY|(constants.O_NOFOLLOW??0));const before=fstatSync(fd);
17
+ if(!before.isFile()||before.size>16384)fail('Record settings must be an ordinary JSON file smaller than 16 KiB.');
18
+ const bytes=Buffer.alloc(16385);let length=0;
19
+ while(length<bytes.length){const count=readSync(fd,bytes,length,bytes.length-length,length);if(!count)break;length+=count;}
20
+ const after=fstatSync(fd);if(length!==before.size||after.size!==before.size||after.mtimeMs!==before.mtimeMs||after.ctimeMs!==before.ctimeMs)fail('Record settings changed while being read. Retry the review.');
21
+ return bytes.toString('utf8',0,length);
22
+ }finally{if(fd!==undefined)closeSync(fd);}
23
+ }
24
+ export function recordSettings(config,safe,destination,existingIds=[]){
25
+ let persisted=null,revision=null;const source=recordSettingsText(config,safe);
26
+ if(source!==null){
27
+ revision=sourceRevision(source);
28
+ try{persisted=JSON.parse(source);}catch{fail('Repair runlist.records.json before creating records.');}
29
+ }
30
+ const configured=config.raw?.records;
31
+ if(configured&&persisted&&JSON.stringify([configured.schema,configured.repositoryId,configured.root,configured.shared])!==JSON.stringify([persisted.schema,persisted.repositoryId,persisted.root,persisted.shared]))fail('The records configuration and runlist.records.json disagree. Reconcile them before creating records.');
32
+ const value=configured??persisted;
33
+ if(value){
34
+ if(value.schema!==1||!new RegExp(`^repo:${uuid}$`).test(value.repositoryId??'')||typeof value.root!=='string'||!value.root||typeof value.shared!=='boolean')fail('Records require schema 1, a persisted repo UUID, a document root and an explicit shared value.');
35
+ destination(`${value.root}/flags/record.md`);destination(`${value.root}/decisions/record.md`);
36
+ return {...value,revision,needsSetup:false};
37
+ }
38
+ const root=path.relative(config.repoRoot,path.join(catchAllRoot(config),'records')).split(path.sep).join('/');
39
+ destination(`${root}/flags/record.md`);
40
+ const identities=[...new Set(existingIds)];if(identities.length>1)fail('Existing native records use multiple repository identities. Choose the repositoryId explicitly in records configuration before creating another.');
41
+ return {schema:1,root,shared:false,revision:null,needsSetup:true,...(identities.length?{repositoryId:identities[0]}:{})};
42
+ }
43
+ export function assertRecordTrackable(config,file,shared){
44
+ if(!shared)return;
45
+ const relative=path.relative(config.repoRoot,file).split(path.sep).join('/');
46
+ try{
47
+ const common={stdio:['ignore','pipe','ignore'],encoding:'utf8',timeout:3000};
48
+ if(execFileSync('git',['-C',config.repoRoot,'rev-parse','--is-inside-work-tree'],common).trim()!=='true')throw new Error('not checkout');
49
+ try{execFileSync('git',['-C',config.repoRoot,'check-ignore','-q','--',relative],common);fail('The shared record destination is ignored by Git. Choose a trackable root; Runlist will not force-add it.');}
50
+ catch(error){if(error.code==='record-configuration')throw error;if(error.status!==1)fail('Cannot verify Git policy for shared records.');}
51
+ }catch(error){if(error.code==='record-configuration')throw error;fail('Shared record creation requires a working Git checkout.');}
52
+ }
53
+ export function renderNewRecord(request,actor,settings,at){
54
+ const allowed=new Set(['operationId','template','title','folder','filename','status','body','severity','options']);
55
+ if(Object.keys(request).some(key=>!allowed.has(key)))throw new SourceEditError('invalid-request','Use only the supported record fields.');
56
+ if(request.status!=='open')throw new SourceEditError('invalid-status','New records start open. Record a ruling or triage separately.');
57
+ const type=request.template,recordId=id(type),repositoryId=settings.repositoryId??id('repo');
58
+ const data={repository_id:repositoryId,created_by:actor,aliases:[],evidence:[],relations:[],history:[{id:id('event'),kind:'created',by:actor,at}]};
59
+ if(type==='flag'){
60
+ if(!['problem','warn','info'].includes(request.severity)||request.options!==undefined)throw new SourceEditError('invalid-request','Choose Problem, Warning or Information for this finding.');
61
+ Object.assign(data,{severity:request.severity,source:{kind:'manual',name:'Runlist',finding_key:recordId},occurrence:1,triage:{disposition:'unreviewed'},resolutions:[]});
62
+ }else{
63
+ if(request.severity!==undefined||!Array.isArray(request.options)||request.options.length<2||request.options.length>12)throw new SourceEditError('invalid-request','Describe between two and twelve alternatives.');
64
+ const text=(value,max)=>typeof value==='string'&&value.trim()&&value.length<=max&&!value.includes('\0');
65
+ const labels=new Set();
66
+ const options=request.options.map(option=>{
67
+ if(!option||Object.keys(option).some(k=>!['label','description','consequences'].includes(k))||!text(option.label,120)||!text(option.description,4000)||!Array.isArray(option.consequences)||option.consequences.length>16)throw new SourceEditError('invalid-request','Each alternative needs a label, description and supported consequences.');
68
+ const label=option.label.trim(),key=label.toLowerCase();if(labels.has(key))throw new SourceEditError('invalid-request','Give each alternative a distinct label.');labels.add(key);
69
+ const consequences=option.consequences.map(c=>{
70
+ if(!c||Object.keys(c).some(k=>!['kind','text'].includes(k))||!['benefit','cost','risk','follow-up'].includes(c.kind)||!text(c.text,4000))throw new SourceEditError('invalid-request','Use a benefit, cost, risk or follow-up with a description.');
71
+ return {kind:c.kind,text:c.text.trim()};
72
+ });return {id:id('option'),label,description:option.description.trim(),consequences};
73
+ });Object.assign(data,{options,recommendations:[],rulings:[]});
74
+ }
75
+ const primary=type==='flag'?'finding':'question';
76
+ const fm={record_schema:NATIVE_RECORD_SCHEMA,id:recordId,type,status:'open',created:at,updated:at,[primary]:request.title.trim()};
77
+ const body=request.body.trim()?request.body:'## Context\n\n';
78
+ const source=`---\n${Object.entries(fm).map(([k,v])=>`${k}: ${JSON.stringify(v)}`).join('\n')}\nrecord_data: |-\n${JSON.stringify(data,null,2).split('\n').map(line=>' '+line).join('\n')}\n---\n${body}${body.endsWith('\n')?'':'\n'}`;
79
+ const parsed=parseNativeRecord(source);if(!parsed.ok)throw new SourceEditError('invalid-record','The new record does not satisfy the native contract.',{diagnostics:parsed.diagnostics});
80
+ const setup=settings.needsSetup?{path:'runlist.records.json',source:JSON.stringify({schema:1,repositoryId,root:settings.root,shared:false},null,2)+'\n'}:null;
81
+ return {source,setup};
82
+ }
@@ -0,0 +1,388 @@
1
+ import { realpathSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter } from './frontmatter.mjs';
4
+ import { maskInlineCodeLine } from './markdown-code-spans.mjs';
5
+
6
+ function slash(value) { return value.split(path.sep).join('/'); }
7
+
8
+ function canonicalExisting(filePath, memo = null) {
9
+ const key = memo ? path.resolve(filePath) : null;
10
+ if (memo) {
11
+ const hit = memo.get(key);
12
+ if (hit !== undefined) return hit;
13
+ }
14
+ const identity = resolveCanonical(filePath);
15
+ if (memo) memo.set(key, identity);
16
+ return identity;
17
+ }
18
+
19
+ function resolveCanonical(filePath) {
20
+ try { return realpathSync(filePath); }
21
+ catch {
22
+ const suffix = [];
23
+ let cursor = path.resolve(filePath);
24
+ while (true) {
25
+ try { return path.join(realpathSync(cursor), ...suffix); }
26
+ catch {
27
+ const parent = path.dirname(cursor);
28
+ if (parent === cursor) return path.resolve(filePath);
29
+ suffix.unshift(path.basename(cursor));
30
+ cursor = parent;
31
+ }
32
+ }
33
+ }
34
+ }
35
+
36
+ export class AmbiguousReferenceError extends Error {
37
+ constructor(token, sourcePath, local, repository) {
38
+ super(`Ambiguous reference '${token}' in ${sourcePath}: document-relative resolves to ${local}, repository-relative resolves to ${repository}. Use an explicit unambiguous path.`);
39
+ this.name = 'AmbiguousReferenceError';
40
+ this.code = 'RUNLIST_AMBIGUOUS_REFERENCE';
41
+ }
42
+ }
43
+
44
+ export function configuredReferenceFields(config) {
45
+ return [...new Set([
46
+ ...(config.referenceFields?.bidirectional ?? []),
47
+ ...(config.referenceFields?.unidirectional ?? []),
48
+ ])];
49
+ }
50
+
51
+ // Marks a case fold shared by two corpus documents. On a case-sensitive
52
+ // filesystem those are genuinely two files, and nothing may collapse them.
53
+ const AMBIGUOUS_FOLD = Symbol('ambiguous case fold');
54
+
55
+ // The set carries four indexes beside the identities themselves:
56
+ // canonical — memoized path resolution. Resolving one reference costs two
57
+ // `canonicalExisting` calls, and the repository-relative spelling usually
58
+ // does NOT exist, which sends it up the tree doing a realpath per ancestor.
59
+ // Over a corpus-wide sweep that is tens of thousands of syscalls across a
60
+ // few hundred distinct paths, and it dominated the reference rewrite. The
61
+ // memo lives here so it is scoped to one sweep and cannot outlive a
62
+ // mutation — `runlist bulk` builds a fresh set per move. Within a sweep it is
63
+ // also more consistent than re-resolving per token, which could observe the
64
+ // filesystem changing midway.
65
+ // paths — identity to the spelling the corpus used.
66
+ // names — identity to every basename it answers to (symlink aliases).
67
+ // folded — case fold to identity, for filesystems that fold case.
68
+ export function createReferenceIdentitySet(filePaths) {
69
+ const identities = new Set();
70
+ identities.paths = new Map();
71
+ identities.canonical = new Map();
72
+ identities.names = new Map();
73
+ identities.folded = new Map();
74
+ identities.symlinked = false;
75
+ for (const filePath of filePaths) registerIdentity(identities, filePath);
76
+ return identities;
77
+ }
78
+
79
+ function registerIdentity(identities, filePath) {
80
+ const resolved = path.resolve(filePath);
81
+ const identity = canonicalExisting(filePath, identities.canonical);
82
+ // A corpus path whose realpath differs is a symlink, so one document can be
83
+ // reachable under more than one name. `names` records every one of them —
84
+ // and `symlinked` warns the candidate prefilter that names are not a
85
+ // reliable signal in this repo at all.
86
+ if (identity !== resolved) identities.symlinked = true;
87
+ identities.add(identity);
88
+ identities.paths.set(identity, resolved);
89
+ let names = identities.names.get(identity);
90
+ if (!names) identities.names.set(identity, names = new Set());
91
+ names.add(path.basename(resolved).toLowerCase());
92
+ const key = identity.toLowerCase();
93
+ const seen = identities.folded.get(key);
94
+ identities.folded.set(key, seen === undefined || seen === identity ? identity : AMBIGUOUS_FOLD);
95
+ return identity;
96
+ }
97
+
98
+ // Can this document possibly hold a reference to `oldIdentity`? Every token the
99
+ // rewriter will touch has to survive `/\.md$/i` and then resolve to the moved
100
+ // file, so the document must spell one of that file's names somewhere. Checking
101
+ // that first skips the fence-aware walk for the ~99% of a corpus that never
102
+ // mentions the file — the measured difference on a 2,000-doc repo is 950ms of
103
+ // rewriting versus 120ms.
104
+ //
105
+ // The comparison mirrors the resolver: case-folded (a case-insensitive
106
+ // filesystem resolves `FOO.MD` to `foo.md`, and folding can only over-include),
107
+ // and with the same backslash escapes unwound, so `my\ plan.md` still matches
108
+ // `my plan.md`. Percent-encoding needs no handling — the resolver does not
109
+ // decode it either, so `foo%20bar.md` never resolves in the first place.
110
+ //
111
+ // The boundary: a symlink whose name differs from its target's. Aliases inside
112
+ // the corpus are covered by `names`; a symlink that is NOT itself a collected
113
+ // doc is not, so any repo that symlinks docs at all fails open to the full walk.
114
+ function mayReferenceIdentity(content, oldIdentity, oldPath, identities) {
115
+ if (!identities?.names || identities.symlinked) return true;
116
+ const names = identities.names.get(oldIdentity);
117
+ const haystack = (content.includes('\\') ? content.replace(/\\([\s()[\]<>])/g, '$1') : content).toLowerCase();
118
+ if (haystack.includes(path.basename(oldPath).toLowerCase())) return true;
119
+ if (names) for (const name of names) if (haystack.includes(name)) return true;
120
+ return false;
121
+ }
122
+
123
+ // Both interpretations are evaluated. A local document wins only when the
124
+ // repo-relative spelling is absent or names the same identity; disagreement is
125
+ // rejected rather than guessed.
126
+ export function resolveReferenceIdentity(token, documentPath, repoRoot, identities) {
127
+ if (!token || /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(token)) return null;
128
+ const clean = token.replace(/[?#].*$/, '').replace(/\\([\s()[\]<>])/g, '$1');
129
+ const memo = identities?.canonical ?? null;
130
+ const local = matchIdentity(canonicalExisting(path.resolve(path.dirname(documentPath), clean), memo), identities);
131
+ const repository = matchIdentity(canonicalExisting(path.resolve(repoRoot, clean.replace(/^\/+/, '')), memo), identities);
132
+ if (local && repository && local !== repository) {
133
+ throw new AmbiguousReferenceError(token, documentPath, local, repository);
134
+ }
135
+ return local ?? repository ?? null;
136
+ }
137
+
138
+ // `realpath` resolves symlinks but NOT case: on a case-insensitive filesystem
139
+ // `realpath("CASING.MD")` hands back the caller's spelling, so an exact compare
140
+ // misses a link that names a real document. Validation disagreed — it resolves
141
+ // with `existsSync`, which does not care about case — so `runlist check` called
142
+ // such a link fine, a move silently left it pointing at the old path, and only
143
+ // THEN did check call it broken.
144
+ //
145
+ // The tie-break is the inode, not a guess about the filesystem: same device and
146
+ // inode means the two spellings are one file, which is only ever true where the
147
+ // filesystem itself folds case. On a case-sensitive filesystem `Foo.md` and
148
+ // `foo.md` are separate inodes and stay separate here, and two corpus documents
149
+ // that differ only by case poison their shared fold so neither is guessed at.
150
+ function matchIdentity(candidate, identities) {
151
+ if (identities.has(candidate)) return candidate;
152
+ const folded = identities.folded?.get(candidate.toLowerCase());
153
+ if (folded === undefined || folded === AMBIGUOUS_FOLD) return null;
154
+ return sameFileOnDisk(candidate, folded) ? folded : null;
155
+ }
156
+
157
+ function sameFileOnDisk(left, right) {
158
+ try {
159
+ const a = statSync(left);
160
+ const b = statSync(right);
161
+ return a.dev === b.dev && a.ino === b.ino && a.ino !== 0;
162
+ } catch { return false; }
163
+ }
164
+
165
+ function rewriteToken(token, sourcePath, outputPath, repoRoot, identities, oldIdentity, newPath, rebaseAll, format = 'plain') {
166
+ const target = resolveReferenceIdentity(token, sourcePath, repoRoot, identities);
167
+ if (!target || (!rebaseAll && target !== oldIdentity)) return token;
168
+ const destination = target === oldIdentity ? path.resolve(newPath) : (identities.paths?.get(target) ?? target);
169
+ const relative = slash(path.relative(path.dirname(outputPath), destination)) || path.basename(destination);
170
+ return format === 'escaped' ? relative.replace(/([\s()[\]<>])/g, '\\$1') : relative;
171
+ }
172
+
173
+ function rewritePathTokens(value, args) {
174
+ let quote = null;
175
+ let commentAt = -1;
176
+ for (let index = 0; index < value.length; index++) {
177
+ const char = value[index];
178
+ if (quote) {
179
+ if (char === quote && value[index - 1] !== '\\') quote = null;
180
+ continue;
181
+ }
182
+ if (char === '"' || char === "'") { quote = char; continue; }
183
+ if (char === '#' && index > 0 && /\s/.test(value[index - 1])) { commentAt = index - 1; break; }
184
+ }
185
+ const editable = commentAt === -1 ? value : value.slice(0, commentAt);
186
+ const comment = commentAt === -1 ? '' : value.slice(commentAt);
187
+ const quoted = editable.replace(/(["'])((?:\\.|(?!\1).)*)\1/g, (match, delimiter, token) => {
188
+ if (!/^.+\.md(?:[?#].*)?$/i.test(token)) return match;
189
+ const suffixAt = token.search(/[?#]/);
190
+ const bare = suffixAt === -1 ? token : token.slice(0, suffixAt);
191
+ const suffix = suffixAt === -1 ? '' : token.slice(suffixAt);
192
+ const rewritten = rewriteToken(bare, ...args);
193
+ return rewritten === bare ? match : `${delimiter}${rewritten}${suffix}${delimiter}`;
194
+ });
195
+ const rewritten = quoted.replace(/[^\s"'<>:[\],()#?]+\.md(?=$|[\s"'<>:[\],()#?])/g, token => rewriteToken(token, ...args));
196
+ return rewritten + comment;
197
+ }
198
+
199
+ function rewriteFrontmatter(frontmatter, fields, args) {
200
+ const allowed = new Set(fields);
201
+ const lines = frontmatter.split('\n');
202
+ let active = false;
203
+ return lines.map(line => {
204
+ const key = /^([A-Za-z_][\w-]*):(?:\s*(.*))?$/.exec(line);
205
+ if (key) {
206
+ active = allowed.has(key[1]);
207
+ if (!active) return line;
208
+ const colon = line.indexOf(':');
209
+ return line.slice(0, colon + 1) + rewritePathTokens(line.slice(colon + 1), args);
210
+ }
211
+ if (/^[^\s#][^:]*:/.test(line)) active = false;
212
+ if (!active || /^\s*#/.test(line)) return line;
213
+ return rewritePathTokens(line, args);
214
+ }).join('\n');
215
+ }
216
+
217
+ function destinationParts(raw) {
218
+ const angle = raw.startsWith('<') && raw.endsWith('>');
219
+ const inner = angle ? raw.slice(1, -1) : raw;
220
+ const suffixAt = inner.search(/[?#]/);
221
+ return {
222
+ angle,
223
+ path: suffixAt === -1 ? inner : inner.slice(0, suffixAt),
224
+ suffix: suffixAt === -1 ? '' : inner.slice(suffixAt),
225
+ };
226
+ }
227
+
228
+ function rewriteDestination(raw, args) {
229
+ const parsed = destinationParts(raw);
230
+ if (!/\.md$/i.test(parsed.path.replace(/\\./g, 'x'))) {
231
+ if (!args[6]) return raw;
232
+ // The moving document carries relative image/directory evidence too.
233
+ // Do not interpret URLs or missing paths as repository documents.
234
+ const clean = parsed.path.replace(/\\([\s()[\]<>])/g, '$1');
235
+ if (!clean || /^(?:[a-z][a-z\d+.-]*:|\/\/|\/|#)/i.test(clean)) return raw;
236
+ const [sourcePath, outputPath, repoRoot] = args;
237
+ const target = path.resolve(path.dirname(sourcePath), clean);
238
+ const relativeToRepo = path.relative(repoRoot, target);
239
+ if (relativeToRepo.startsWith('..' + path.sep) || path.isAbsolute(relativeToRepo)) return raw;
240
+ try { statSync(target); } catch { return raw; }
241
+ const next = slash(path.relative(path.dirname(outputPath), target));
242
+ const rendered = (parsed.angle ? next : next.replace(/([\s()[\]<>])/g, '\\$1')) + parsed.suffix;
243
+ return parsed.angle ? `<${rendered}>` : rendered;
244
+ }
245
+ const next = rewriteToken(parsed.path, ...args, parsed.angle ? 'plain' : 'escaped');
246
+ if (next === parsed.path) return raw;
247
+ const rendered = `${next}${parsed.suffix}`;
248
+ return parsed.angle ? `<${rendered}>` : rendered;
249
+ }
250
+
251
+ function rewriteMarkdownLine(line, masked, args) {
252
+ const edits = [];
253
+ const inline = /(\[[^\]]*\]\(\s*)(<[^>\n]+>|(?:\\.|[^\s()])+)(\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?(\s*\))/g;
254
+ let match;
255
+ while ((match = inline.exec(masked)) !== null) {
256
+ let escapes = 0;
257
+ for (let index = match.index - 1; index >= 0 && line[index] === '\\'; index--) escapes++;
258
+ if (escapes % 2 === 1) continue;
259
+ const start = match.index + match[1].length;
260
+ const destination = line.slice(start, start + match[2].length);
261
+ const rewritten = rewriteDestination(destination, args);
262
+ if (rewritten !== destination) edits.push({ start, end: start + destination.length, value: rewritten });
263
+ }
264
+
265
+ // Reference definitions preserve labels, spacing, destinations, and titles.
266
+ const definition = /^(\s{0,3}\[[^\]]+\]:\s*)(<[^>\n]+>|(?:\\.|[^\s])+)(.*)$/.exec(masked);
267
+ if (definition) {
268
+ const start = definition[1].length;
269
+ const destination = line.slice(start, start + definition[2].length);
270
+ const rewritten = rewriteDestination(destination, args);
271
+ if (rewritten !== destination) edits.push({ start, end: start + destination.length, value: rewritten });
272
+ }
273
+
274
+ return edits.sort((left, right) => right.start - left.start)
275
+ .reduce((next, edit) => next.slice(0, edit.start) + edit.value + next.slice(edit.end), line);
276
+ }
277
+
278
+ function rewriteMarkdown(body, args) {
279
+ const lines = body.split('\n');
280
+ let fence = null;
281
+ const inlineState = { run: null };
282
+ let indentedCode = false;
283
+ let htmlBlock = null;
284
+ const containerContext = line => {
285
+ let rest = line;
286
+ let quotePrefix = '';
287
+ while (true) {
288
+ const quote = /^( {0,3}>[ \t]?)/.exec(rest);
289
+ if (!quote) break;
290
+ quotePrefix += quote[1];
291
+ rest = rest.slice(quote[1].length);
292
+ }
293
+ const list = /^( {0,3}(?:[-+*]|\d+[.)]))([ \t]+)/.exec(rest);
294
+ if (list) {
295
+ const padding = !list[2].includes('\t') && list[2].length <= 4 ? list[2] : list[2][0];
296
+ const listPrefix = `${list[1]}${padding}`;
297
+ rest = rest.slice(listPrefix.length);
298
+ return { content: rest, prefixes: [`${quotePrefix}${listPrefix}`, `${quotePrefix}${' '.repeat(listPrefix.length)}`] };
299
+ }
300
+ return { content: rest, prefixes: quotePrefix ? [quotePrefix] : [''] };
301
+ };
302
+ return lines.map(line => {
303
+ if (fence) {
304
+ const close = new RegExp(`^ {0,3}${fence.char === '`' ? '`' : '~'}{${fence.length},}[ \\t]*$`);
305
+ const compatible = fence.prefixes.find(prefix => line.startsWith(prefix));
306
+ if (compatible !== undefined && close.test(line.slice(compatible.length))) fence = null;
307
+ return line;
308
+ }
309
+ if (htmlBlock) {
310
+ if (htmlBlock === 'comment' && line.includes('-->')) htmlBlock = null;
311
+ else if (htmlBlock !== 'generic' && new RegExp(`</${htmlBlock}\\s*>`, 'i').test(line)) htmlBlock = null;
312
+ else if (htmlBlock === 'generic' && line.trim() === '') htmlBlock = null;
313
+ return line;
314
+ }
315
+ const trimmed = line.trimStart();
316
+ if (trimmed.startsWith('<!--')) {
317
+ if (!trimmed.includes('-->')) htmlBlock = 'comment';
318
+ return line;
319
+ }
320
+ const rawTag = /^<(pre|code|script|style)(?:\s|>|$)/i.exec(trimmed)?.[1]?.toLowerCase();
321
+ if (rawTag) {
322
+ if (!new RegExp(`</${rawTag}\\s*>`, 'i').test(trimmed)) htmlBlock = rawTag;
323
+ return line;
324
+ }
325
+ if (/^<\/?(?:address|article|aside|base|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)(?:\s|>|\/)/i.test(trimmed)) {
326
+ htmlBlock = 'generic';
327
+ return line;
328
+ }
329
+ if (trimmed.startsWith('<')) return line;
330
+ const container = containerContext(line);
331
+ if (indentedCode) {
332
+ if (container.content === '' || /^(?: {4}|\t)/.test(container.content)) return line;
333
+ indentedCode = false;
334
+ } else if (/^(?: {4}|\t)/.test(container.content)) {
335
+ indentedCode = true;
336
+ return line;
337
+ }
338
+ const opener = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(container.content);
339
+ if (opener) {
340
+ fence = { char: opener[1][0], length: opener[1].length, prefixes: container.prefixes };
341
+ return line;
342
+ }
343
+ return rewriteMarkdownLine(line, maskInlineCodeLine(line, inlineState), args);
344
+ }).join('\n');
345
+ }
346
+
347
+ export function rewriteDocumentReferences(content, {
348
+ sourcePath,
349
+ outputPath = sourcePath,
350
+ repoRoot,
351
+ identities,
352
+ oldPath,
353
+ newPath,
354
+ referenceFields = [],
355
+ rebaseAll = false,
356
+ }) {
357
+ const { frontmatter, body } = extractFrontmatter(content);
358
+ if (!frontmatter) return content;
359
+ const oldIdentity = canonicalExisting(oldPath, identities?.canonical ?? null);
360
+ // `rebaseAll` rewrites every reference the document holds because the
361
+ // document itself moved, so no single name can gate it.
362
+ if (!rebaseAll && !mayReferenceIdentity(content, oldIdentity, oldPath, identities)) return content;
363
+ const args = [sourcePath, outputPath, repoRoot, identities, oldIdentity, newPath, rebaseAll];
364
+ const nextFrontmatter = rewriteFrontmatter(frontmatter, referenceFields, args);
365
+ const nextBody = rewriteMarkdown(body, args);
366
+ return nextFrontmatter === frontmatter && nextBody === body ? content : `---\n${nextFrontmatter}\n---\n${nextBody}`;
367
+ }
368
+
369
+ export function planReferenceMove({ documents, oldPath, newPath, repoRoot, referenceFields = [] }) {
370
+ const identities = createReferenceIdentitySet(documents.map(document => document.path));
371
+ // Register rather than bare-add: the case fold and name index have to know
372
+ // about the moved document too, or a differently-cased link to it misses.
373
+ const oldIdentity = registerIdentity(identities, oldPath);
374
+ const source = documents.find(document => path.resolve(document.path) === path.resolve(oldPath));
375
+ if (!source) throw new Error(`Reference move plan is missing source content: ${oldPath}`);
376
+ const movedContent = rewriteDocumentReferences(source.content, {
377
+ sourcePath: oldPath, outputPath: newPath, repoRoot, identities, oldPath, newPath, referenceFields, rebaseAll: true,
378
+ });
379
+ const updates = [];
380
+ for (const document of documents) {
381
+ if (path.resolve(document.path) === path.resolve(oldPath)) continue;
382
+ const content = rewriteDocumentReferences(document.content, {
383
+ sourcePath: document.path, repoRoot, identities, oldPath, newPath, referenceFields,
384
+ });
385
+ if (content !== document.content) updates.push({ path: document.path, expectedContent: document.content, content });
386
+ }
387
+ return { movedContent, updates, identities };
388
+ }
@@ -0,0 +1,11 @@
1
+ import { normalizeStringList } from './util.mjs';
2
+
3
+ export function referenceValues(value) {
4
+ const paths = [], directions = [];
5
+ for (const entry of normalizeStringList(value)) {
6
+ const oneWay = entry.match(/^>\s*(.+)$/);
7
+ paths.push(oneWay ? oneWay[1].trim() : entry);
8
+ directions.push(oneWay ? 'one-way' : 'two-way');
9
+ }
10
+ return { paths, directions };
11
+ }
package/src/rename.mjs ADDED
@@ -0,0 +1,107 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { toRepoPath, die, warn } from './util.mjs';
4
+ import { collectDocFiles, resolveDocArg } from './index.mjs';
5
+ import { regenIndex } from './lifecycle.mjs';
6
+ import { captureGitIndexGeneration, isTracked } from './git.mjs';
7
+ import { green, dim } from './color.mjs';
8
+ import { isInteractive, promptText } from './prompt.mjs';
9
+ import { authorizeManagedDestination, authorizeManagedSource, authorizeManagedSweep } from './managed-path.mjs';
10
+ import { availableSessionId, prepareOwnershipMigration } from './pickup.mjs';
11
+ import { moveFileAtomic } from './atomic-mutation.mjs';
12
+ import { configuredReferenceFields, createReferenceIdentitySet, planReferenceMove, rewriteDocumentReferences } from './reference-planner.mjs';
13
+ import { reportMovedCodeRefs } from './code-refs.mjs';
14
+
15
+ export async function runRename(argv, config, opts = {}) {
16
+ const { dryRun } = opts;
17
+ const fixCodeRefs = argv.includes('--fix-refs') || opts.fixRefs;
18
+ const codeRefStrings = argv.includes('--strings');
19
+ const positional = argv.filter(arg => !arg.startsWith('-'));
20
+ const oldInput = positional[0];
21
+ let newInput = positional[1];
22
+ if (!oldInput) die('Usage: runlist rename <old> <new>');
23
+ if (!newInput) {
24
+ if (!isInteractive()) die('Usage: runlist rename <old> <new>');
25
+ newInput = await promptText('New name: ');
26
+ if (!newInput) die('No name provided.');
27
+ }
28
+
29
+ let oldPath = resolveDocArg(oldInput, config);
30
+ const sourceAuthorization = authorizeManagedSource(oldPath, config, { kind: 'Rename source' });
31
+ oldPath = sourceAuthorization.path;
32
+ let newPath;
33
+ if (newInput.includes('/') || newInput.includes(path.sep)) {
34
+ newPath = path.resolve(config.repoRoot, newInput.endsWith('.md') ? newInput : `${newInput}.md`);
35
+ } else {
36
+ newPath = path.join(path.dirname(oldPath), newInput.endsWith('.md') ? newInput : `${newInput}.md`);
37
+ }
38
+ if (existsSync(newPath)) die(`Target already exists: ${toRepoPath(newPath, config.repoRoot)}`);
39
+ newPath = authorizeManagedDestination(newPath, config, { root: sourceAuthorization.root, kind: 'Rename destination' }).path;
40
+
41
+ const oldRepoPath = toRepoPath(oldPath, config.repoRoot);
42
+ const newRepoPath = toRepoPath(newPath, config.repoRoot);
43
+ const allFiles = collectDocFiles(config);
44
+ authorizeManagedSweep(allFiles, config, { kind: 'Rename reference rewrite source' });
45
+ const documents = allFiles.map(filePath => ({ path: filePath, content: readFileSync(filePath, 'utf8') }));
46
+ let sourceDocument = documents.find(document => document.path === oldPath);
47
+ if (!sourceDocument) {
48
+ sourceDocument = { path: oldPath, content: readFileSync(oldPath, 'utf8') };
49
+ documents.push(sourceDocument);
50
+ }
51
+ const sourceContent = sourceDocument.content;
52
+ const referenceFields = configuredReferenceFields(config);
53
+ const referencePlan = planReferenceMove({ documents, oldPath, newPath, repoRoot: config.repoRoot, referenceFields });
54
+ const ownership = prepareOwnershipMigration(oldRepoPath, newPath, config, { sessionId: availableSessionId() });
55
+
56
+ if (dryRun) {
57
+ const prefix = dim('[dry-run]');
58
+ process.stdout.write(`${prefix} Would rename: ${oldRepoPath} → ${newRepoPath}\n`);
59
+ if (referencePlan.updates.length) {
60
+ process.stdout.write(`${prefix} Would update references in ${referencePlan.updates.length} file(s):\n`);
61
+ for (const item of referencePlan.updates) process.stdout.write(`${prefix} ${toRepoPath(item.path, config.repoRoot)}\n`);
62
+ }
63
+ if (ownership) process.stdout.write(`${prefix} Would migrate this session's ownership record.\n`);
64
+ reportMovedCodeRefs(config, oldRepoPath, newRepoPath, { dryRun: true, prefix: `${prefix} ` });
65
+ return;
66
+ }
67
+
68
+ const tracked = isTracked(oldPath, config.repoRoot);
69
+ const gitIndex = tracked ? captureGitIndexGeneration(config.repoRoot) : null;
70
+ const identities = createReferenceIdentitySet(allFiles);
71
+ const result = moveFileAtomic(oldPath, newPath, current => rewriteDocumentReferences(current, {
72
+ sourcePath: oldPath,
73
+ outputPath: newPath,
74
+ repoRoot: config.repoRoot,
75
+ identities,
76
+ oldPath,
77
+ newPath,
78
+ referenceFields,
79
+ rebaseAll: true,
80
+ }), {
81
+ repoRoot: config.repoRoot,
82
+ config,
83
+ updates: allFiles.filter(filePath => filePath !== oldPath).map(filePath => ({
84
+ path: filePath,
85
+ render: current => rewriteDocumentReferences(current, {
86
+ sourcePath: filePath, repoRoot: config.repoRoot, identities, oldPath, newPath, referenceFields,
87
+ }),
88
+ })),
89
+ creations: ownership ? [{ path: ownership.newRecordPath, content: ownership.newContent, label: 'ownership' }] : [],
90
+ deletions: ownership ? [{ path: ownership.oldRecordPath, expectedContent: ownership.oldContent, label: 'ownership' }] : [],
91
+ gitMove: tracked,
92
+ gitIndex,
93
+ operation: 'rename',
94
+ sessionId: availableSessionId(),
95
+ testHooks: opts.testHooks,
96
+ });
97
+ regenIndex(config);
98
+ process.stdout.write(`${green('Renamed')}: ${oldRepoPath} → ${newRepoPath}\n`);
99
+ if (result.updatedPaths.length) process.stdout.write(`Updated references in ${result.updatedPaths.length} file(s).\n`);
100
+ // The doc-root repair above is committed. The code roots are a separate
101
+ // sweep outside the move transaction, so the count is reported and the
102
+ // write waits for --fix-refs.
103
+ reportMovedCodeRefs(config, oldRepoPath, newRepoPath, { fix: fixCodeRefs, strings: codeRefStrings });
104
+ try { config.hooks.onRename?.({ oldPath: oldRepoPath, newPath: newRepoPath, referencesUpdated: result.updatedPaths.length }); }
105
+ catch (err) { warn(`Hook 'onRename' threw: ${err.message}`); }
106
+ return { oldRepoPath, newRepoPath, referencePaths: result.updatedPaths.map(item => toRepoPath(item, config.repoRoot)) };
107
+ }