@nimiplatform/nimi-coding 0.3.1 → 0.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.
Files changed (115) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/CONTRIBUTING.md +3 -3
  3. package/README.md +297 -41
  4. package/README.zh-CN.md +306 -31
  5. package/bin/nimicoding.mjs +1 -1
  6. package/cli/commands/authority.mjs +804 -0
  7. package/cli/commands/clear.mjs +27 -7
  8. package/cli/commands/doctor.mjs +10 -1
  9. package/cli/commands/start.mjs +23 -27
  10. package/cli/commands/sync.mjs +21 -101
  11. package/cli/constants.mjs +7 -31
  12. package/cli/dev/check-authority-surface.mjs +182 -0
  13. package/cli/dev/check-product-conformance.mjs +889 -0
  14. package/cli/help.mjs +10 -17
  15. package/cli/index.mjs +6 -47
  16. package/cli/lib/authority/anchors.mjs +491 -0
  17. package/cli/lib/authority/audit.mjs +567 -0
  18. package/cli/lib/authority/change-candidates.mjs +807 -0
  19. package/cli/lib/authority/closed-sets.mjs +645 -0
  20. package/cli/lib/authority/compile.mjs +92 -0
  21. package/cli/lib/authority/compiled-snapshot.mjs +143 -0
  22. package/cli/lib/authority/consumers.mjs +520 -0
  23. package/cli/lib/authority/diagnostics.mjs +93 -0
  24. package/cli/lib/authority/diff.mjs +310 -0
  25. package/cli/lib/authority/discover.mjs +447 -0
  26. package/cli/lib/authority/format.mjs +169 -0
  27. package/cli/lib/authority/git-snapshot.mjs +720 -0
  28. package/cli/lib/authority/graph.mjs +351 -0
  29. package/cli/lib/authority/impact.mjs +125 -0
  30. package/cli/lib/authority/lexicon.mjs +55 -0
  31. package/cli/lib/authority/query.mjs +162 -0
  32. package/cli/lib/authority/repository-corpus.mjs +37 -0
  33. package/cli/lib/authority/repository-inventory.mjs +34 -0
  34. package/cli/lib/authority/review.mjs +212 -0
  35. package/cli/lib/authority/sarif.mjs +174 -0
  36. package/cli/lib/authority/scope-bindings.mjs +225 -0
  37. package/cli/lib/authority/source-map.mjs +105 -0
  38. package/cli/lib/authority/source-markdown.mjs +167 -0
  39. package/cli/lib/authority/source-yaml.mjs +308 -0
  40. package/cli/lib/authority/surface.mjs +97 -0
  41. package/cli/lib/authority/terms.mjs +422 -0
  42. package/cli/lib/authority/tracked-text.mjs +70 -0
  43. package/cli/lib/authority/validate.mjs +300 -0
  44. package/cli/lib/bootstrap.mjs +55 -71
  45. package/cli/lib/doctor.mjs +40 -123
  46. package/cli/lib/entrypoints.mjs +104 -105
  47. package/cli/lib/fs-helpers.mjs +139 -15
  48. package/cli/lib/internal/governance/config.mjs +0 -36
  49. package/cli/lib/shared.mjs +8 -21
  50. package/cli/lib/sync.mjs +81 -92
  51. package/cli/lib/value-helpers.mjs +0 -26
  52. package/cli/lib/yaml-helpers.mjs +1 -124
  53. package/cli/seeds/bootstrap.mjs +31 -87
  54. package/cli/seeds/seed-policy.yaml +9 -63
  55. package/contracts/authority-scope-bindings.schema.yaml +15 -0
  56. package/contracts/authority-source.schema.yaml +58 -0
  57. package/contracts/authority-verifier-bindings.schema.yaml +31 -0
  58. package/methodology/authority-authoring.yaml +127 -0
  59. package/methodology/core.yaml +34 -27
  60. package/package.json +10 -6
  61. package/spec/authority-anchors.yaml +99 -0
  62. package/spec/authority-audit.yaml +89 -0
  63. package/spec/authority-authoring.yaml +137 -0
  64. package/spec/authority-change-candidates.yaml +146 -0
  65. package/spec/authority-closed-sets.yaml +65 -0
  66. package/spec/authority-consumers.yaml +77 -0
  67. package/spec/authority-context.yaml +50 -0
  68. package/spec/authority-discovery.yaml +119 -0
  69. package/spec/authority-graph.yaml +59 -0
  70. package/spec/authority-impact.yaml +61 -0
  71. package/spec/authority-review.yaml +71 -0
  72. package/spec/authority-terms.yaml +63 -0
  73. package/spec/product-scope.yaml +37 -23
  74. package/cli/commands/blueprint-audit.mjs +0 -91
  75. package/cli/commands/classify-spec-tree.mjs +0 -5
  76. package/cli/commands/generate-spec-derived-docs.mjs +0 -151
  77. package/cli/commands/generate-spec-migration-plan.mjs +0 -30
  78. package/cli/commands/surface-validator-command.mjs +0 -49
  79. package/cli/commands/validate-domain-admission.mjs +0 -5
  80. package/cli/commands/validate-guidance-bodies.mjs +0 -5
  81. package/cli/commands/validate-placement.mjs +0 -5
  82. package/cli/commands/validate-projection-edges.mjs +0 -5
  83. package/cli/commands/validate-spec-audit.mjs +0 -27
  84. package/cli/commands/validate-spec-governance.mjs +0 -144
  85. package/cli/commands/validate-spec-tree.mjs +0 -27
  86. package/cli/commands/validate-table-family.mjs +0 -5
  87. package/cli/commands/validate-tracked-output-admission.mjs +0 -5
  88. package/cli/lib/blueprint-audit.mjs +0 -363
  89. package/cli/lib/contracts.mjs +0 -21
  90. package/cli/lib/internal/contracts-loaders.mjs +0 -47
  91. package/cli/lib/internal/contracts-parse.mjs +0 -192
  92. package/cli/lib/internal/governance/runner.mjs +0 -35
  93. package/cli/lib/internal/surface-taxonomy-validators.mjs +0 -1147
  94. package/cli/lib/internal/validators-shared.mjs +0 -12
  95. package/cli/lib/internal/validators-spec-helpers.mjs +0 -52
  96. package/cli/lib/internal/validators-spec.mjs +0 -380
  97. package/cli/lib/validators.mjs +0 -30
  98. package/config/bootstrap.yaml +0 -6
  99. package/config/spec-generation-inputs.yaml +0 -41
  100. package/contracts/domain-admission.schema.yaml +0 -56
  101. package/contracts/migration-inventory.schema.yaml +0 -15
  102. package/contracts/negative-fixtures.yaml +0 -26
  103. package/contracts/placement-contract.schema.yaml +0 -127
  104. package/contracts/projection-edge.schema.yaml +0 -46
  105. package/contracts/shared-enums.yaml +0 -63
  106. package/contracts/spec-generation-audit.schema.yaml +0 -46
  107. package/contracts/spec-generation-inputs.schema.yaml +0 -43
  108. package/contracts/spec-layout.schema.yaml +0 -32
  109. package/contracts/surface-taxonomy.schema.yaml +0 -139
  110. package/contracts/table-family.schema.yaml +0 -133
  111. package/contracts/tracked-output-admission.schema.yaml +0 -17
  112. package/methodology/four-closure-policy.yaml +0 -20
  113. package/methodology/role-separation-policy.yaml +0 -22
  114. package/methodology/spec-reconstruction.yaml +0 -69
  115. package/spec/_meta/spec-tree-model.yaml +0 -44
@@ -1,11 +1,8 @@
1
1
  import { readdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
- import { SPEC_GENERATION_AUDIT_REF } from "../constants.mjs";
5
4
  import { getBootstrapSeedEntries } from "../seeds/bootstrap.mjs";
6
- import { inspectBootstrapCompatibility } from "./bootstrap.mjs";
7
- import { loadSpecGenerationInputsConfig } from "./contracts.mjs";
8
- import { pathExists, readTextIfFile } from "./fs-helpers.mjs";
5
+ import { pathExists, preflightManagedProjectPaths } from "./fs-helpers.mjs";
9
6
  import { runSeedSync, SYNC_MODE } from "./sync.mjs";
10
7
  import { localize, styleHeading, styleLabel, styleStatus } from "./ui.mjs";
11
8
 
@@ -15,111 +12,47 @@ function check(id, ok, severity, detail) {
15
12
 
16
13
  async function directoryHasFiles(root) {
17
14
  const info = await pathExists(root);
18
- if (!info || !info.isDirectory()) return false;
19
- const entries = await readdir(root, { withFileTypes: true });
20
- return entries.length > 0;
21
- }
22
-
23
- async function managedEntrypoints(projectRoot) {
24
- const refs = [];
25
- for (const ref of ["AGENTS.md", "CLAUDE.md"]) {
26
- const text = await readTextIfFile(path.join(projectRoot, ref));
27
- if (text?.includes("nimicoding:managed:")) refs.push(ref);
28
- }
29
- return refs;
15
+ if (!info?.isDirectory()) return false;
16
+ return (await readdir(root)).length > 0;
30
17
  }
31
18
 
32
19
  export async function inspectDoctorState(projectRoot) {
33
- const checks = [];
34
- const bootstrap = await inspectBootstrapCompatibility(projectRoot);
35
- checks.push(check(
36
- "bootstrap_contract",
37
- bootstrap.status === "supported",
38
- "error",
39
- bootstrap.status === "supported"
40
- ? "bootstrap contract is supported"
41
- : "bootstrap contract is missing or unsupported",
42
- ));
43
-
44
- let seedSync = null;
45
- if (bootstrap.status === "supported") {
46
- seedSync = await runSeedSync(projectRoot, SYNC_MODE.CHECK);
47
- checks.push(check(
48
- "managed_projection",
49
- seedSync.ok,
20
+ await preflightManagedProjectPaths(projectRoot, { includeSpec: true });
21
+ const managedProjection = await runSeedSync(projectRoot, SYNC_MODE.CHECK);
22
+ const specPresent = await directoryHasFiles(path.join(projectRoot, ".nimi/spec"));
23
+ const checks = [
24
+ check(
25
+ "managed_surfaces",
26
+ managedProjection.ok,
50
27
  "error",
51
- seedSync.ok
52
- ? `all ${seedSync.summary.total} package-managed files are aligned`
53
- : `${seedSync.checkFailures.length} package-managed files are missing or drifted`,
28
+ managedProjection.ok
29
+ ? "exact package projection and managed instruction blocks are aligned"
30
+ : `${managedProjection.checkFailures.length} exact managed or deprecated surface issue(s) require attention`,
31
+ ),
32
+ check(
33
+ "canonical_authority",
34
+ true,
35
+ "info",
36
+ specPresent
37
+ ? "canonical authority is present; run nimicoding authority check .nimi/spec for admission"
38
+ : "canonical authority has not been authored; the project owns all product semantics under .nimi/spec",
39
+ ),
40
+ ];
41
+ if (process.platform === "win32") {
42
+ checks.push(check(
43
+ "snapshot_no_follow_capability",
44
+ false,
45
+ "info",
46
+ "win32 does not expose O_NOFOLLOW/O_DIRECTORY; snapshot no-follow hardening is downgraded to surrounding lstat/realpath validation",
54
47
  ));
55
48
  }
56
-
57
- const generationInputs = await loadSpecGenerationInputsConfig(projectRoot);
58
- checks.push(check(
59
- "spec_generation_inputs",
60
- generationInputs.ok && generationInputs.mode === "class_filtered",
61
- "error",
62
- generationInputs.ok && generationInputs.mode === "class_filtered"
63
- ? "spec generation inputs use class-filtered authority"
64
- : "spec generation inputs are missing or invalid",
65
- ));
66
-
67
- const specRoot = path.join(projectRoot, ".nimi", "spec");
68
- const specPresent = await directoryHasFiles(specRoot);
69
- checks.push(check(
70
- "spec_tree",
71
- true,
72
- "info",
73
- specPresent
74
- ? "canonical spec tree is present; conformance is checked by validate-spec-tree or project-specific validators"
75
- : "canonical spec tree has not been constructed yet",
76
- ));
77
-
78
- const auditPath = path.join(projectRoot, SPEC_GENERATION_AUDIT_REF);
79
- const auditPresent = Boolean(await pathExists(auditPath));
80
- checks.push(check(
81
- "spec_generation_audit",
82
- true,
83
- "info",
84
- auditPresent
85
- ? "spec generation audit is present; conformance is checked by validate-spec-audit"
86
- : "spec generation audit is not present",
87
- ));
88
-
89
- const entrypoints = await managedEntrypoints(projectRoot);
90
- checks.push(check(
91
- "entrypoint_integration",
92
- true,
93
- "info",
94
- entrypoints.length > 0
95
- ? `managed methodology guidance is present in ${entrypoints.join(", ")}`
96
- : "managed methodology guidance is not installed",
97
- ));
98
-
99
49
  const seedEntries = await getBootstrapSeedEntries();
100
- const ok = checks.every((entry) => entry.severity !== "error");
101
-
102
50
  return {
103
- ok,
51
+ ok: checks.every((entry) => entry.severity !== "error"),
104
52
  projectRoot,
105
- bootstrap,
106
- managedProjection: seedSync,
107
- generationInputs: {
108
- ok: generationInputs.ok,
109
- mode: generationInputs.mode ?? null,
110
- canonicalTargetRoot: generationInputs.canonicalTargetRoot ?? null,
111
- },
112
- spec: {
113
- present: specPresent,
114
- ok: null,
115
- summary: null,
116
- },
117
- generationAudit: {
118
- present: auditPresent,
119
- ok: null,
120
- summary: null,
121
- },
122
- entrypoints,
53
+ bootstrap: { status: "exact_projection_registry" },
54
+ managedProjection,
55
+ spec: { present: specPresent, ok: null, summary: null },
123
56
  managedFileCount: seedEntries.length,
124
57
  checks,
125
58
  };
@@ -131,33 +64,17 @@ export function formatDoctorResult(result, options = {}) {
131
64
  "",
132
65
  styleLabel(localize("Summary:", "摘要:")),
133
66
  ` - ${localize("status", "状态")}: ${styleStatus(result.ok ? "ok" : "needs_attention")}`,
134
- ` - bootstrap: ${result.bootstrap.status}`,
135
- ` - managed_projection: ${result.managedProjection?.ok === true ? "aligned" : "invalid"}`,
136
- ` - spec: ${!result.spec.present ? "not_constructed" : "present_not_validated"}`,
137
- ` - generation_audit: ${!result.generationAudit.present ? "not_present" : "present_not_validated"}`,
67
+ ` - projection_policy: ${result.bootstrap.status}`,
68
+ ` - managed_surfaces: ${result.managedProjection.ok ? "aligned" : "invalid"}`,
69
+ ` - canonical_authority: ${result.spec.present ? "present_not_validated" : "not_authored"}`,
138
70
  "",
139
71
  styleLabel(localize("Checks:", "检查项:")),
140
72
  ];
141
-
142
- const visibleChecks = options.verbose
143
- ? result.checks
144
- : result.checks.filter((entry) => entry.severity !== "ok" || !entry.ok);
145
- if (visibleChecks.length === 0) {
146
- lines.push(` - ${localize("No blocking issues found.", "没有发现阻塞问题。")}`);
147
- } else {
148
- for (const entry of visibleChecks) {
149
- const marker = entry.ok ? "info" : entry.severity;
150
- lines.push(` - [${marker}] ${entry.detail}`);
151
- }
152
- }
153
-
73
+ const visible = options.verbose ? result.checks : result.checks.filter((entry) => entry.severity !== "ok" || !entry.ok);
74
+ if (visible.length === 0) lines.push(` - ${localize("No blocking integration issues found.", "没有发现阻塞性的集成问题。")}`);
75
+ else for (const entry of visible) lines.push(` - [${entry.ok ? "info" : entry.severity}] ${entry.detail}`);
154
76
  if (!result.spec.present) {
155
- lines.push(
156
- "",
157
- styleLabel(localize("Next:", "下一步:")),
158
- " - Build canonical product authority under .nimi/spec, then run nimicoding validate-spec-tree.",
159
- );
77
+ lines.push("", styleLabel(localize("Next:", "下一步:")), " - Author canonical authority under .nimi/spec, format changed files, then run nimicoding authority check .nimi/spec.");
160
78
  }
161
-
162
79
  return `${lines.join("\n")}\n`;
163
80
  }
@@ -1,4 +1,4 @@
1
- import { readFile, rm, writeFile } from "node:fs/promises";
1
+ import { writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
4
  import {
@@ -7,101 +7,80 @@ import {
7
7
  CLAUDE_BEGIN,
8
8
  CLAUDE_END,
9
9
  } from "../constants.mjs";
10
- import { pathExists } from "./fs-helpers.mjs";
10
+ import { pathExists, preflightManagedProjectPaths, readUtf8FileFatal } from "./fs-helpers.mjs";
11
11
 
12
- function managedAgentsBlock() {
12
+ export class ManagedBlockError extends Error {
13
+ constructor(message) {
14
+ super(message);
15
+ this.name = "ManagedBlockError";
16
+ }
17
+ }
18
+
19
+ function authorityAuthoringLines() {
13
20
  return [
14
- AGENTS_BEGIN,
15
- "# Nimi Coding Managed Block",
16
- "",
17
- "- Product and repository authority lives under `/.nimi/spec/**`.",
18
- "- Read `/.nimi/spec/INDEX.md` first, then the affected kernel contracts and tables.",
19
- "- Read `/.nimi/methodology/**` for reasoning principles and `/.nimi/contracts/**` for spec construction and validation contracts.",
20
- "- Treat `/.nimi/{config,contracts,methodology}/**` as package-managed governance support, never as product authority.",
21
- "- Keep generation evidence under `/.nimi/local/state/spec-generation/**`; it must not become product truth.",
22
- "- Do not treat this managed block as a replacement for project-specific rules.",
23
- AGENTS_END,
24
- ].join("\n");
21
+ "- Product authority lives under `.nimi/spec/**`.",
22
+ "- For canonical authority authoring, read only `.nimi/methodology/authority-authoring.yaml`, the affected authority files or bounded task context, and CLI diagnostics.",
23
+ "- Use `nimicoding authority context <path> <id> --max-units <n> --max-bytes <n> --json` only for the complete declared outgoing interpretation closure; it is not complete task context, and failure never permits guessed or partial context.",
24
+ "- Use `nimicoding authority diff` and `authority impact` with explicit `--max-bytes`; impact reports declared review obligations and does not prove implementation, consumers, or tests are synchronized.",
25
+ "- Use `nimicoding authority change-candidates` only with explicit channels and budgets; its complete union is recall input, never conflict, retirement, absence, authority, or conformance judgment.",
26
+ "- Under `.nimi/spec/**`, author only closed multi-unit `*.authority.yaml` containers or single-unit `*.authority.md`; historical document formats are unsupported and never inferred.",
27
+ "- Run `nimicoding authority fmt` on each changed file, then `nimicoding authority check` on the complete authority input set.",
28
+ "- Never bypass a failure with inferred or fallback semantics; choose repair values only from product/task authority.",
29
+ "- Keep derived and local verification output under `.nimi/local/**`; it is never product authority.",
30
+ ];
31
+ }
32
+
33
+ function managedAgentsBlock() {
34
+ return [AGENTS_BEGIN, "# Nimi Coding Managed Block", "", ...authorityAuthoringLines(), AGENTS_END].join("\n");
25
35
  }
26
36
  function managedClaudeBlock() {
27
- return [
28
- CLAUDE_BEGIN,
29
- "# Nimi Coding Managed Block",
30
- "",
31
- "Authority order:",
32
- "1. `/.nimi/spec/**`",
33
- "2. `/.nimi/methodology/**`",
34
- "3. `/.nimi/contracts/**`",
35
- "4. `/.nimi/config/**`",
36
- "5. repository-local AI entry files",
37
- "",
38
- "Nimi Coding owns methodology, canonical spec construction, and deterministic validation only.",
39
- "Keep local generation evidence outside product authority.",
40
- CLAUDE_END,
41
- ].join("\n");
42
- }
43
-
44
- function escapeRegExp(value) {
45
- return value.replace(/[.*+?^$()|[\]{}\\]/g, "\\$&");
46
- }
47
-
48
- function blockPattern(begin, end, leading = false) {
49
- return new RegExp(
50
- `${leading ? "(?:\\n\\n)?" : ""}${escapeRegExp(begin)}[\\s\\S]*?${escapeRegExp(end)}${leading ? "(?:\\n)?" : ""}`,
51
- );
52
- }
53
-
54
- function upsertManagedBlock(existing, begin, end, block) {
55
- const pattern = blockPattern(begin, end);
56
- if (pattern.test(existing)) {
57
- return existing.replace(pattern, block);
37
+ return [CLAUDE_BEGIN, "# Nimi Coding Managed Block", "", ...authorityAuthoringLines(), CLAUDE_END].join("\n");
38
+ }
39
+
40
+ function occurrenceIndexes(text, marker) {
41
+ const indexes = [];
42
+ let offset = 0;
43
+ while (offset <= text.length) {
44
+ const index = text.indexOf(marker, offset);
45
+ if (index === -1) break;
46
+ indexes.push(index);
47
+ offset = index + marker.length;
58
48
  }
59
- const prefix = existing.length === 0 ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
60
- return `${existing}${prefix}${block}\n`;
49
+ return indexes;
61
50
  }
62
51
 
63
- function removeManagedBlock(existing, begin, end) {
64
- const pattern = blockPattern(begin, end, true);
65
- if (!pattern.test(existing)) {
66
- return { changed: false, next: existing, deleteFile: false };
52
+ export function analyzeManagedBlockText(existing, begin, end, expectedBlock, label = "managed block") {
53
+ const begins = occurrenceIndexes(existing, begin);
54
+ const ends = occurrenceIndexes(existing, end);
55
+ if (begins.length === 0 && ends.length === 0) return { state: "missing", begin: -1, end: -1, exact: false };
56
+ if (begins.length !== 1 || ends.length !== 1) {
57
+ throw new ManagedBlockError(`${label} markers must contain exactly one ordered, non-nested begin/end pair`);
67
58
  }
68
- const next = existing
69
- .replace(pattern, "\n")
70
- .replace(/\n{3,}/g, "\n\n")
71
- .replace(/^\n+/, "")
72
- .trimEnd();
59
+ if (begins[0] >= ends[0]) throw new ManagedBlockError(`${label} markers must contain exactly one ordered, non-nested begin/end pair`);
60
+ const endOffset = ends[0] + end.length;
61
+ const actualBlock = existing.slice(begins[0], endOffset);
73
62
  return {
74
- changed: true,
75
- next: next.length === 0 ? "" : `${next}\n`,
76
- deleteFile: next.length === 0,
63
+ state: actualBlock === expectedBlock ? "exact" : "drifted",
64
+ begin: begins[0],
65
+ end: endOffset,
66
+ exact: actualBlock === expectedBlock,
77
67
  };
78
68
  }
79
69
 
80
- function computeTextFileUpdate(existing, block, begin, end, header) {
81
- const base = existing ?? header;
82
- const next = upsertManagedBlock(base, begin, end, block);
83
- return { changed: next !== base, next };
84
- }
85
-
86
- async function upsertTextFile(filePath, block, begin, end, header) {
87
- const existing = (await pathExists(filePath)) ? await readFile(filePath, "utf8") : header;
88
- const computed = computeTextFileUpdate(existing, block, begin, end, header);
89
- if (!computed.changed) return false;
90
- await writeFile(filePath, computed.next, "utf8");
91
- return true;
70
+ function upsertManagedBlock(existing, analysis, block) {
71
+ if (analysis.state === "missing") {
72
+ const prefix = existing.length === 0 ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
73
+ return `${existing}${prefix}${block}\n`;
74
+ }
75
+ return `${existing.slice(0, analysis.begin)}${block}${existing.slice(analysis.end)}`;
92
76
  }
93
77
 
94
- async function removeManagedTextFile(filePath, begin, end, emptyFallbackHeader = null) {
95
- const existing = (await pathExists(filePath)) ? await readFile(filePath, "utf8") : null;
96
- if (existing === null) return { changed: false, removedFile: false };
97
- const computed = removeManagedBlock(existing, begin, end);
98
- if (!computed.changed) return { changed: false, removedFile: false };
99
- if (computed.deleteFile || (emptyFallbackHeader && computed.next.trim() === emptyFallbackHeader.trim())) {
100
- await rm(filePath, { force: true });
101
- return { changed: true, removedFile: true };
102
- }
103
- await writeFile(filePath, computed.next, "utf8");
104
- return { changed: true, removedFile: false };
78
+ function removeManagedBlock(existing, analysis) {
79
+ if (analysis.state === "missing") return { changed: false, next: existing };
80
+ return {
81
+ changed: true,
82
+ next: `${existing.slice(0, analysis.begin)}${existing.slice(analysis.end)}`,
83
+ };
105
84
  }
106
85
 
107
86
  function entrypointDefinitions(projectRoot) {
@@ -125,46 +104,66 @@ function entrypointDefinitions(projectRoot) {
125
104
  ];
126
105
  }
127
106
 
107
+ async function inspectDefinition(file) {
108
+ const info = await pathExists(file.path);
109
+ const existing = info?.isFile() ? await readUtf8FileFatal(file.path, file.relativePath) : null;
110
+ const base = existing ?? file.header;
111
+ const analysis = analyzeManagedBlockText(base, file.begin, file.end, file.block, `${file.relativePath} nimicoding managed block`);
112
+ const next = upsertManagedBlock(base, analysis, file.block);
113
+ return { ...file, existing, base, analysis, next, changed: next !== base };
114
+ }
115
+
116
+ export async function inspectEntrypointIntegration(projectRoot) {
117
+ await preflightManagedProjectPaths(projectRoot);
118
+ const states = [];
119
+ for (const file of entrypointDefinitions(projectRoot)) states.push(await inspectDefinition(file));
120
+ return states;
121
+ }
122
+
128
123
  export async function previewEntrypointIntegration(projectRoot) {
129
- const updates = [];
130
- for (const file of entrypointDefinitions(projectRoot)) {
131
- const existing = (await pathExists(file.path)) ? await readFile(file.path, "utf8") : null;
132
- if (computeTextFileUpdate(existing, file.block, file.begin, file.end, file.header).changed) {
133
- updates.push(file.relativePath);
134
- }
135
- }
136
- return updates;
124
+ return (await inspectEntrypointIntegration(projectRoot)).filter((state) => state.changed).map((state) => state.relativePath);
137
125
  }
138
126
 
139
127
  export async function integrateEntrypoints(projectRoot) {
128
+ const states = await inspectEntrypointIntegration(projectRoot);
140
129
  const updated = [];
141
- for (const file of entrypointDefinitions(projectRoot)) {
142
- if (await upsertTextFile(file.path, file.block, file.begin, file.end, file.header)) {
143
- updated.push(file.relativePath);
144
- }
130
+ for (const state of states) {
131
+ if (!state.changed) continue;
132
+ await writeFile(state.path, state.next, "utf8");
133
+ updated.push(state.relativePath);
145
134
  }
146
135
  return updated;
147
136
  }
148
137
 
149
- export async function previewEntrypointRemoval(projectRoot) {
150
- const updates = [];
138
+ async function inspectEntrypointRemoval(projectRoot) {
139
+ await preflightManagedProjectPaths(projectRoot);
140
+ const states = [];
151
141
  for (const file of entrypointDefinitions(projectRoot)) {
152
- const existing = (await pathExists(file.path)) ? await readFile(file.path, "utf8") : null;
153
- if (existing !== null && removeManagedBlock(existing, file.begin, file.end).changed) {
154
- updates.push(file.relativePath);
142
+ const info = await pathExists(file.path);
143
+ const existing = info?.isFile() ? await readUtf8FileFatal(file.path, file.relativePath) : null;
144
+ if (existing === null) {
145
+ states.push({ ...file, existing, removal: { changed: false, next: "" } });
146
+ continue;
155
147
  }
148
+ const analysis = analyzeManagedBlockText(existing, file.begin, file.end, file.block, `${file.relativePath} nimicoding managed block`);
149
+ const removal = removeManagedBlock(existing, analysis);
150
+ states.push({ ...file, existing, removal });
156
151
  }
157
- return updates;
152
+ return states;
153
+ }
154
+
155
+ export async function previewEntrypointRemoval(projectRoot) {
156
+ return (await inspectEntrypointRemoval(projectRoot)).filter((state) => state.removal.changed).map((state) => state.relativePath);
158
157
  }
159
158
 
160
159
  export async function removeManagedEntrypoints(projectRoot) {
160
+ const states = await inspectEntrypointRemoval(projectRoot);
161
161
  const updatedFiles = [];
162
162
  const removedFiles = [];
163
- for (const file of entrypointDefinitions(projectRoot)) {
164
- const result = await removeManagedTextFile(file.path, file.begin, file.end, file.header.trim());
165
- if (result.changed) {
166
- (result.removedFile ? removedFiles : updatedFiles).push(file.relativePath);
167
- }
163
+ for (const state of states) {
164
+ if (!state.removal.changed) continue;
165
+ await writeFile(state.path, state.removal.next, "utf8");
166
+ updatedFiles.push(state.relativePath);
168
167
  }
169
168
  return { updatedFiles, removedFiles };
170
169
  }
@@ -1,33 +1,157 @@
1
- import { readFile, stat, writeFile } from "node:fs/promises";
1
+ import { lstat, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const SHARED_INODE_FORBIDDEN_PATHS = new Set([
5
+ ".nimi/methodology/authority-authoring.yaml",
6
+ "AGENTS.md",
7
+ "CLAUDE.md",
8
+ ".gitignore",
9
+ ".gitattributes",
10
+ ]);
11
+ const FATAL_UTF8_HOST_PATHS = new Set(["AGENTS.md", "CLAUDE.md", ".gitignore", ".gitattributes"]);
12
+
13
+ const BASE_MANAGED_PATHS = [
14
+ [".nimi", "directory"],
15
+ [".nimi/local", "directory"],
16
+ [".nimi/methodology", "directory"],
17
+ [".nimi/methodology/authority-authoring.yaml", "file"],
18
+ [".nimi/config", "directory"],
19
+ [".nimi/config/spec-generation-inputs.yaml", "file"],
20
+ [".nimi/contracts", "directory"],
21
+ [".nimi/contracts/domain-admission.schema.yaml", "file"],
22
+ ["AGENTS.md", "file"],
23
+ ["CLAUDE.md", "file"],
24
+ [".gitignore", "file"],
25
+ [".gitattributes", "file"],
26
+ ];
27
+
28
+ export class ManagedPathError extends Error {
29
+ constructor(message) {
30
+ super(message);
31
+ this.name = "ManagedPathError";
32
+ }
33
+ }
34
+
35
+ export class ManagedTextError extends Error {
36
+ constructor(message) {
37
+ super(message);
38
+ this.name = "ManagedTextError";
39
+ }
40
+ }
2
41
 
3
42
  export async function pathExists(targetPath) {
4
43
  try {
5
- return await stat(targetPath);
44
+ return await lstat(targetPath);
45
+ } catch (error) {
46
+ if (error?.code === "ENOENT") return null;
47
+ throw error;
48
+ }
49
+ }
50
+
51
+ function portable(value) {
52
+ return value.split(path.sep).join(path.posix.sep);
53
+ }
54
+
55
+ async function inspectManagedPath(projectRoot, relativePath, expectedType) {
56
+ const components = relativePath.split("/");
57
+ let current = projectRoot;
58
+ for (let index = 0; index < components.length; index += 1) {
59
+ current = path.join(current, components[index]);
60
+ let info;
61
+ try {
62
+ info = await lstat(current);
63
+ } catch (error) {
64
+ if (error?.code === "ENOENT") return;
65
+ throw new ManagedPathError(`cannot inspect managed path ${portable(path.relative(projectRoot, current))}: ${error.message}`);
66
+ }
67
+ const ref = portable(path.relative(projectRoot, current));
68
+ if (info.isSymbolicLink()) throw new ManagedPathError(`managed path boundary refuses symbolic link: ${ref}`);
69
+ const isTarget = index === components.length - 1;
70
+ if (!isTarget && !info.isDirectory()) throw new ManagedPathError(`managed path ancestor must be a directory: ${ref}`);
71
+ if (isTarget && expectedType === "directory" && !info.isDirectory()) throw new ManagedPathError(`managed path must be a directory: ${ref}`);
72
+ if (isTarget && expectedType === "file" && !info.isFile()) throw new ManagedPathError(`managed path must be a regular file: ${ref}`);
73
+ if (isTarget && info.isFile() && SHARED_INODE_FORBIDDEN_PATHS.has(relativePath) && info.nlink > 1) {
74
+ throw new ManagedPathError(`managed regular file must not share an inode: ${ref}`);
75
+ }
76
+ if (isTarget && info.isFile() && FATAL_UTF8_HOST_PATHS.has(relativePath)) await readUtf8FileFatal(current, ref);
77
+ }
78
+ }
79
+
80
+ export async function preflightManagedProjectPaths(projectRoot, options = {}) {
81
+ const absoluteRoot = path.resolve(projectRoot);
82
+ let rootInfo;
83
+ try {
84
+ rootInfo = await lstat(absoluteRoot);
85
+ } catch (error) {
86
+ throw new ManagedPathError(`cannot inspect project root: ${error.message}`);
87
+ }
88
+ if (rootInfo.isSymbolicLink()) throw new ManagedPathError("managed path boundary refuses symbolic-link project root");
89
+ if (!rootInfo.isDirectory()) throw new ManagedPathError("project root must be a directory");
90
+ const specs = options.includeSpec
91
+ ? [...BASE_MANAGED_PATHS, [".nimi/spec", "directory"]]
92
+ : BASE_MANAGED_PATHS;
93
+ for (const [relativePath, expectedType] of specs) {
94
+ await inspectManagedPath(absoluteRoot, relativePath, expectedType);
95
+ }
96
+ return true;
97
+ }
98
+
99
+ export async function readUtf8FileFatal(filePath, label = filePath) {
100
+ const bytes = await readFile(filePath);
101
+ try {
102
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
6
103
  } catch {
7
- return null;
104
+ throw new ManagedTextError(`managed host text is not valid UTF-8: ${label}`);
8
105
  }
9
106
  }
10
107
 
11
108
  export async function readTextIfFile(filePath) {
12
109
  const info = await pathExists(filePath);
13
- if (!info || !info.isFile()) {
14
- return null;
15
- }
16
-
110
+ if (!info || !info.isFile() || info.isSymbolicLink()) return null;
17
111
  return readFile(filePath, "utf8");
18
112
  }
19
113
 
20
- export async function appendGitignoreEntries(gitignorePath, entries) {
21
- const existing = (await pathExists(gitignorePath))
22
- ? await readFile(gitignorePath, "utf8")
23
- : "";
24
- const missing = entries.filter((entry) => !existing.includes(entry));
114
+ export function normalizeTextToLf(text) {
115
+ return String(text).replace(/\r\n?/gu, "\n");
116
+ }
25
117
 
26
- if (missing.length === 0) {
27
- return false;
28
- }
118
+ function exactTextLines(text) {
119
+ return String(text ?? "")
120
+ .split(/\n/u)
121
+ .map((line) => line.endsWith("\r") ? line.slice(0, -1) : line);
122
+ }
123
+
124
+ export function hasExactTextLine(text, entry) {
125
+ return exactTextLines(text).includes(entry);
126
+ }
127
+
128
+ function effectiveGitignoreRuleLines(text) {
129
+ return String(text ?? "")
130
+ .split(/\n/u)
131
+ .map((line) => line.endsWith("\r") ? line.slice(0, -1) : line)
132
+ .filter((line) => line.length > 0 && !line.startsWith("#"));
133
+ }
134
+
135
+ export function hasExactGitignoreRule(text, entry) {
136
+ return effectiveGitignoreRuleLines(text).at(-1) === entry;
137
+ }
29
138
 
139
+ export async function appendGitignoreEntries(gitignorePath, entries) {
140
+ const existingInfo = await pathExists(gitignorePath);
141
+ const existing = existingInfo?.isFile() ? await readUtf8FileFatal(gitignorePath, ".gitignore") : "";
142
+ const missing = entries.filter((entry) => !hasExactGitignoreRule(existing, entry));
143
+ if (missing.length === 0) return false;
30
144
  const prefix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
31
145
  await writeFile(gitignorePath, `${existing}${prefix}${missing.join("\n")}\n`, "utf8");
32
146
  return true;
33
147
  }
148
+
149
+ export async function appendGitattributesEntries(gitattributesPath, entries) {
150
+ const existingInfo = await pathExists(gitattributesPath);
151
+ const existing = existingInfo?.isFile() ? await readUtf8FileFatal(gitattributesPath, ".gitattributes") : "";
152
+ const missing = entries.filter((entry) => !hasExactTextLine(existing, entry));
153
+ if (missing.length === 0) return false;
154
+ const prefix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
155
+ await writeFile(gitattributesPath, `${existing}${prefix}${missing.join("\n")}\n`, "utf8");
156
+ return true;
157
+ }